[Gekidou] Groups + group-memberships deferred fetch (#6370)

* WIP

* Actions updated to fetch remote first, and local on error

* Groups fetch and save

* PR Feedback: prepare vs store and undefined fix

* Forgot to add file

* Groups Mention WIP

* Groups highlight!

* Merge, PR Feedback

* PR Feedback

* PR Feedback: Try/Catch blocks

* PR Feedback

* Rebased with PR feedback

* Exclusion fix, plus id order

* Tidies up iterations

* Loops updated

* Update app/database/operator/server_data_operator/handlers/group.ts

Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>

* PR Feedback: Remove unnecessary prepare/store methods

* Newline ESLint error

* Extracts out id generation for group-associations

* Batches if not fetchOnly

Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>
This commit is contained in:
Shaz MJ
2022-07-07 20:20:06 +10:00
committed by GitHub
parent b2d838d3da
commit dcfc6e7927
12 changed files with 158 additions and 59 deletions

View File

@@ -3,7 +3,7 @@
import {fetchFilteredChannelGroups, fetchFilteredTeamGroups, fetchGroupsForAutocomplete} from '@actions/remote/groups';
import DatabaseManager from '@database/manager';
import {prepareGroups, queryGroupsByName, queryGroupsByNameInChannel, queryGroupsByNameInTeam} from '@queries/servers/group';
import {queryGroupsByName, queryGroupsByNameInChannel, queryGroupsByNameInTeam} from '@queries/servers/group';
import {logError} from '@utils/log';
import type GroupModel from '@typings/database/models/servers/group';
@@ -61,8 +61,7 @@ export const searchGroupsByNameInChannel = async (serverUrl: string, name: strin
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (e) {
// eslint-disable-next-line no-console
console.log('searchGroupsByNameInChannel - DB Error', e);
logError('searchGroupsByNameInChannel - DB Error', e);
return [];
}
@@ -79,25 +78,3 @@ export const searchGroupsByNameInChannel = async (serverUrl: string, name: strin
}
};
/**
* Store fetched groups locally
*
* @param serverUrl string - The Server URL
* @param groups Group[] - The groups fetched from the API
*/
export const storeGroups = async (serverUrl: string, groups: Group[]) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const preparedGroups = await prepareGroups(operator, groups);
if (preparedGroups.length) {
operator.batchRecords(preparedGroups);
}
return preparedGroups;
} catch (e) {
return {error: e};
}
};

View File

@@ -29,6 +29,8 @@ import {deleteMyTeams, getAvailableTeamIds, getNthLastChannelFromTeam, queryMyTe
import {isDMorGM} from '@utils/channel';
import {processIsCRTEnabled} from '@utils/thread';
import {fetchGroupsForMember} from '../groups';
import type ClientError from '@client/rest/error';
export type AppEntryData = {
@@ -305,6 +307,10 @@ export async function deferredAppEntryActions(
await fetchAllTeams(serverUrl);
await updateAllUsersSince(serverUrl, since);
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
setTimeout(async () => {
if (channelsToFetchProfiles?.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);

View File

@@ -1,11 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {storeGroups} from '@actions/local/group';
import {Client} from '@client/rest';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {prepareGroups} from '@queries/servers/group';
import {forceLogoutIfNecessary} from './session';
@@ -15,12 +13,7 @@ export const fetchGroupsForAutocomplete = async (serverUrl: string, query: strin
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getGroups(query);
// Save locally
if (!fetchOnly) {
return storeGroups(serverUrl, response);
}
return prepareGroups(operator, response);
return operator.handleGroups({groups: response, prepareRecordsOnly: fetchOnly});
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
@@ -41,11 +34,7 @@ export const fetchGroupsByNames = async (serverUrl: string, names: string[], fet
const groups = (await Promise.all(promises)).flat();
// Save locally
if (!fetchOnly) {
return storeGroups(serverUrl, groups);
}
return prepareGroups(operator, groups);
return operator.handleGroups({groups, prepareRecordsOnly: fetchOnly});
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
@@ -58,11 +47,7 @@ export const fetchGroupsForChannel = async (serverUrl: string, channelId: string
const client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToChannel(channelId);
if (!fetchOnly) {
return storeGroups(serverUrl, response.groups);
}
return prepareGroups(operator, response.groups);
return operator.handleGroups({groups: response.groups, prepareRecordsOnly: fetchOnly});
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
@@ -75,17 +60,33 @@ export const fetchGroupsForTeam = async (serverUrl: string, teamId: string, fetc
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToTeam(teamId);
if (!fetchOnly) {
return storeGroups(serverUrl, response.groups);
}
return prepareGroups(operator, response.groups);
return operator.handleGroups({groups: response.groups, prepareRecordsOnly: fetchOnly});
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchGroupsForMember = async (serverUrl: string, userId: string, fetchOnly = false) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client: Client = NetworkManager.getClient(serverUrl);
const response = await client.getAllGroupsAssociatedToMembership(userId);
const groups = await operator.handleGroups({groups: response, prepareRecordsOnly: true});
const groupMemberships = await operator.handleGroupMembershipsForMember({groups: response, userId, prepareRecordsOnly: true});
if (!fetchOnly) {
await operator.batchRecords([...groups, ...groupMemberships]);
}
return {groups, groupMemberships};
} catch (error) {
return {error};
}
};
export const fetchFilteredTeamGroups = async (serverUrl: string, searchTerm: string, teamId: string) => {
try {
const groups = await fetchGroupsForTeam(serverUrl, teamId);

View File

@@ -8,10 +8,10 @@ import {PER_PAGE_DEFAULT} from './constants';
export interface ClientGroupsMix {
getGroups: (query?: string, filterAllowReference?: boolean, page?: number, perPage?: number, since?: number) => Promise<Group[]>;
getAllGroupsAssociatedToChannel: (channelId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>;
getAllGroupsAssociatedToMembership: (userId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>;
getAllGroupsAssociatedToMembership: (userId: string, filterAllowReference?: boolean) => Promise<Group[]>;
getAllGroupsAssociatedToTeam: (teamId: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>;
getAllChannelsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupChannels: GroupChannel[]}>;
getAllMembershipsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupMemberships: GroupMembership; total_member_count: number}>;
getAllMembershipsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupMemberships: UserProfile[]; total_member_count: number}>;
getAllTeamsAssociatedToGroup: (groupId: string, filterAllowReference?: boolean) => Promise<{groupTeams: GroupTeam[]}>;
}

View File

@@ -22,6 +22,7 @@ import {bottomSheetSnapPoint} from '@utils/helpers';
import {displayUsername, getUsersByUsername} from '@utils/user';
import type GroupModelType from '@typings/database/models/servers/group';
import type GroupMembershipModel from '@typings/database/models/servers/group_membership';
import type UserModelType from '@typings/database/models/servers/user';
type AtMentionProps = {
@@ -39,6 +40,7 @@ type AtMentionProps = {
textStyle?: StyleProp<TextStyle>;
users: UserModelType[];
groups: GroupModel[];
groupMemberships: GroupMembershipModel[];
}
const {SERVER: {GROUP, USER}} = MM_TABLES;
@@ -62,6 +64,7 @@ const AtMention = ({
textStyle,
users,
groups,
groupMemberships,
}: AtMentionProps) => {
const intl = useIntl();
const managedConfig = useManagedConfig<ManagedConfig>();
@@ -223,6 +226,7 @@ const AtMention = ({
canPress = true;
} else if (group?.name) {
mention = group.name;
highlighted = groupMemberships.some((gm) => gm.groupId === group.id);
isMention = true;
canPress = false;
} else {

View File

@@ -3,8 +3,9 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {switchMap} from 'rxjs/operators';
import {queryGroupsByName} from '@app/queries/servers/group';
import {queryGroupsByName, queryGroupMembershipForMember} from '@queries/servers/group';
import {observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay, queryUsersLike} from '@queries/servers/user';
@@ -26,6 +27,7 @@ const enhance = withObservables(['mentionName'], ({database, mentionName}: {ment
teammateNameDisplay,
users: queryUsersLike(database, mn).observeWithColumns(['username']),
groups: queryGroupsByName(database, mn).observeWithColumns(['name']),
groupMemberships: currentUserId.pipe(switchMap((userId) => queryGroupMembershipForMember(database, userId).observe())),
};
});

View File

@@ -2,16 +2,21 @@
// See LICENSE.txt for license information.
import {MM_TABLES} from '@constants/database';
import {transformGroupRecord} from '@database/operator/server_data_operator/transformers/group';
import {transformGroupMembershipRecord, transformGroupRecord} from '@database/operator/server_data_operator/transformers/group';
import {getUniqueRawsBy} from '@database/operator/utils/general';
import {queryGroupMembershipForMember} from '@queries/servers/group';
import {generateGroupAssociationId} from '@utils/groups';
import {logWarning} from '@utils/log';
import type {HandleGroupArgs} from '@typings/database/database';
import type {HandleGroupArgs, HandleGroupMembershipForMemberArgs} from '@typings/database/database';
import type GroupModel from '@typings/database/models/servers/group';
import type GroupMembershipModel from '@typings/database/models/servers/group_membership';
const {GROUP, GROUP_MEMBERSHIP} = MM_TABLES.SERVER;
const {GROUP} = MM_TABLES.SERVER;
export interface GroupHandlerMix {
handleGroups: ({groups, prepareRecordsOnly}: HandleGroupArgs) => Promise<GroupModel[]>;
handleGroupMembershipsForMember: ({userId, groups, prepareRecordsOnly}: HandleGroupMembershipForMemberArgs) => Promise<GroupMembershipModel[]>;
}
const GroupHandler = (superclass: any) => class extends superclass implements GroupHandlerMix {
@@ -41,6 +46,66 @@ const GroupHandler = (superclass: any) => class extends superclass implements Gr
prepareRecordsOnly,
});
};
/**
* handleGroupMembershipsForMember: Handler responsible for the Create/Update operations occurring on the GroupMembership table from the 'Server' schema
* @param {string} userId
* @param {HandleGroupMembershipForMemberArgs} groupMembershipsArgs
* @param {GroupMembership[]} groupMembershipsArgs.groupMemberships
* @param {boolean} groupMembershipsArgs.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Promise<GroupMembershipModel[]>}
*/
handleGroupMembershipsForMember = async ({userId, groups, prepareRecordsOnly = true}: HandleGroupMembershipForMemberArgs): Promise<GroupMembershipModel[]> => {
// Get existing group memberships
const existingGroupMemberships = await queryGroupMembershipForMember(this.database, userId).fetch();
let records: GroupMembershipModel[] = [];
let rawValues: GroupMembership[] = [];
// Nothing to add or remove
if (!groups?.length && !existingGroupMemberships.length) {
return records;
} else if (!groups?.length && existingGroupMemberships.length) { // No groups - remove all existing ones
records = existingGroupMemberships.map((gm) => gm.prepareDestroyPermanently());
} else if (groups?.length && !existingGroupMemberships.length) { // No existing groups - add all new ones
rawValues = groups.map((g) => ({id: generateGroupAssociationId(g.id, userId), user_id: userId, group_id: g.id}));
} else if (groups?.length && existingGroupMemberships.length) { // If both, we only want to save new ones and delete one's no longer in groups
const groupsSet: {[key: string]: GroupMembership} = {};
for (const g of groups) {
groupsSet[`${g.id}`] = {id: generateGroupAssociationId(g.id, userId), user_id: userId, group_id: g.id};
}
for (const gm of existingGroupMemberships) {
// Check if existingGroups overlaps with groups
if (groupsSet[gm.groupId]) {
// If there is an existing group already, we don't need to add it
delete groupsSet[gm.groupId];
} else {
// No group? Remove existing one
records.push(gm.prepareDestroyPermanently());
}
}
rawValues.push(...Object.values(groupsSet));
}
records.push(...(await this.handleRecords({
fieldName: 'id',
transformer: transformGroupMembershipRecord,
rawValues,
tableName: GROUP_MEMBERSHIP,
prepareRecordsOnly: true,
})));
// Batch update if there are records
if (records.length && !prepareRecordsOnly) {
await this.batchRecords(records);
}
return records;
};
};
export default GroupHandler;

View File

@@ -5,12 +5,15 @@
import {MM_TABLES} from '@constants/database';
import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index';
import {OperationType} from '@typings/database/enums';
import {generateGroupAssociationId} from '@utils/groups';
import type {TransformerArgs} from '@typings/database/database';
import type GroupModel from '@typings/database/models/servers/group';
import type GroupMembershipModel from '@typings/database/models/servers/group_membership';
const {
GROUP,
GROUP_MEMBERSHIP,
} = MM_TABLES.SERVER;
/**
@@ -42,3 +45,29 @@ export const transformGroupRecord = ({action, database, value}: TransformerArgs)
fieldsMapper,
}) as Promise<GroupModel>;
};
/**
* transformGroupMembershipRecord: Prepares a record of the SERVER database 'GroupMembership' table for update or create actions.
* @param {TransformerArgs} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<GroupMembershipModel>}
*/
export const transformGroupMembershipRecord = ({action, database, value}: TransformerArgs): Promise<GroupMembershipModel> => {
const raw = value.raw as GroupMembership;
// id of group comes from server response
const fieldsMapper = (model: GroupMembershipModel) => {
model._raw.id = raw.id || generateGroupAssociationId(raw.group_id, raw.user_id);
model.groupId = raw.group_id;
model.userId = raw.user_id;
};
return prepareBaseRecord({
action,
database,
tableName: GROUP_MEMBERSHIP,
value,
fieldsMapper,
}) as Promise<GroupMembershipModel>;
};

View File

@@ -5,10 +5,10 @@ import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type GroupModel from '@typings/database/models/servers/group';
import type GroupMembershipModel from '@typings/database/models/servers/group_membership';
const {SERVER: {GROUP, GROUP_CHANNEL, GROUP_TEAM}} = MM_TABLES;
const {SERVER: {GROUP, GROUP_CHANNEL, GROUP_MEMBERSHIP, GROUP_TEAM}} = MM_TABLES;
export const queryGroupsByName = (database: Database, name: string) => {
return database.collections.get<GroupModel>(GROUP).query(
@@ -36,6 +36,8 @@ export const queryGroupsByNameInChannel = (database: Database, name: string, cha
);
};
export const prepareGroups = (operator: ServerDataOperator, groups: Group[]) => {
return operator.handleGroups({groups, prepareRecordsOnly: true});
export const queryGroupMembershipForMember = (database: Database, userId: string) => {
return database.collections.get<GroupMembershipModel>(GROUP_MEMBERSHIP).query(
Q.where('user_id', userId),
);
};

4
app/utils/groups.ts Normal file
View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const generateGroupAssociationId = (groupId: string, otherId: string) => `${groupId}-${otherId}`;

View File

@@ -42,4 +42,8 @@ type GroupChannel = {
update_at: number;
}
type GroupMembership = UserProfile[]
type GroupMembership = {
id?: string;
group_id: string;
user_id: string;
}

View File

@@ -222,6 +222,11 @@ export type HandleGroupArgs = PrepareOnly & {
groups?: Group[];
};
export type HandleGroupMembershipForMemberArgs = PrepareOnly & {
userId: string;
groups?: Group[];
}
export type HandleCategoryChannelArgs = PrepareOnly & {
categoryChannels?: CategoryChannel[];
};