diff --git a/app/database/admin/data_operator/handlers/index.ts b/app/database/admin/data_operator/handlers/index.ts deleted file mode 100644 index 72ad57858d..0000000000 --- a/app/database/admin/data_operator/handlers/index.ts +++ /dev/null @@ -1,1421 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Database, Q} from '@nozbe/watermelondb'; -import Model from '@nozbe/watermelondb/Model'; - -import {MM_TABLES} from '@constants/database'; -import { - isRecordAppEqualToRaw, - isRecordChannelEqualToRaw, - isRecordChannelInfoEqualToRaw, - isRecordChannelMembershipEqualToRaw, - isRecordCustomEmojiEqualToRaw, - isRecordDraftEqualToRaw, - isRecordGlobalEqualToRaw, - isRecordGroupEqualToRaw, - isRecordGroupMembershipEqualToRaw, - isRecordGroupsInChannelEqualToRaw, - isRecordGroupsInTeamEqualToRaw, - isRecordMyChannelEqualToRaw, - isRecordMyChannelSettingsEqualToRaw, - isRecordMyTeamEqualToRaw, - isRecordPostEqualToRaw, - isRecordPreferenceEqualToRaw, - isRecordRoleEqualToRaw, - isRecordServerEqualToRaw, - isRecordSlashCommandEqualToRaw, - isRecordSystemEqualToRaw, - isRecordTeamChannelHistoryEqualToRaw, - isRecordTeamEqualToRaw, - isRecordTeamMembershipEqualToRaw, - isRecordTeamSearchHistoryEqualToRaw, - isRecordTermsOfServiceEqualToRaw, - isRecordUserEqualToRaw, -} from '@database/admin/data_operator/comparators'; -import DatabaseManager from '@database/admin/database_manager'; -import CustomEmoji from '@typings/database/custom_emoji'; -import { - BatchOperationsArgs, - DatabaseInstance, - HandleEntityRecordsArgs, - HandleFilesArgs, - HandleIsolatedEntityArgs, - HandlePostMetadataArgs, - HandlePostsArgs, - HandleReactionsArgs, - MatchExistingRecord, - PostImage, - PrepareForDatabaseArgs, - PrepareRecordsArgs, - ProcessInputsArgs, - RawChannel, - RawChannelInfo, - RawChannelMembership, - RawCustomEmoji, - RawDraft, - RawEmbed, - RawFile, - RawGroup, - RawGroupMembership, - RawGroupsInChannel, - RawGroupsInTeam, - RawMyChannel, - RawMyChannelSettings, - RawMyTeam, - RawPost, - RawPostMetadata, - RawPostsInThread, - RawPreference, - RawReaction, - RawSlashCommand, - RawTeam, - RawTeamChannelHistory, - RawTeamMembership, - RawTeamSearchHistory, - RawUser, - RawValue, -} from '@typings/database/database'; -import {IsolatedEntities, OperationType} from '@typings/database/enums'; -import File from '@typings/database/file'; -import Post from '@typings/database/post'; -import PostMetadata from '@typings/database/post_metadata'; -import PostsInChannel from '@typings/database/posts_in_channel'; -import PostsInThread from '@typings/database/posts_in_thread'; -import Reaction from '@typings/database/reaction'; - -import DataOperatorException from '../../exceptions/data_operator_exception'; -import DatabaseConnectionException from '../../exceptions/database_connection_exception'; -import { - operateAppRecord, - operateChannelInfoRecord, - operateChannelMembershipRecord, - operateChannelRecord, - operateCustomEmojiRecord, - operateDraftRecord, - operateFileRecord, - operateGlobalRecord, - operateGroupMembershipRecord, - operateGroupRecord, - operateGroupsInChannelRecord, - operateGroupsInTeamRecord, - operateMyChannelRecord, - operateMyChannelSettingsRecord, - operateMyTeamRecord, - operatePostInThreadRecord, - operatePostMetadataRecord, - operatePostRecord, - operatePostsInChannelRecord, - operatePreferenceRecord, - operateReactionRecord, - operateRoleRecord, - operateServersRecord, - operateSlashCommandRecord, - operateSystemRecord, - operateTeamChannelHistoryRecord, - operateTeamMembershipRecord, - operateTeamRecord, - operateTeamSearchHistoryRecord, - operateTermsOfServiceRecord, - operateUserRecord, -} from '../operators'; -import { - createPostsChain, - getRangeOfValues, - getRawRecordPairs, - getUniqueRawsBy, - hasSimilarUpdateAt, - retrieveRecords, - sanitizePosts, - sanitizeReactions, -} from '../utils'; - -const { - CHANNEL, - CHANNEL_INFO, - CHANNEL_MEMBERSHIP, - CUSTOM_EMOJI, - DRAFT, - FILE, - GROUP, - GROUPS_IN_CHANNEL, - GROUPS_IN_TEAM, - GROUP_MEMBERSHIP, - MY_CHANNEL, - MY_CHANNEL_SETTINGS, - MY_TEAM, - POST, - POSTS_IN_CHANNEL, - POSTS_IN_THREAD, - POST_METADATA, - PREFERENCE, - REACTION, - SLASH_COMMAND, - TEAM, - TEAM_CHANNEL_HISTORY, - TEAM_MEMBERSHIP, - TEAM_SEARCH_HISTORY, - USER, -} = MM_TABLES.SERVER; - -class DataOperator { - /** - * serverDatabase : In a multi-server configuration, this connection will be used by WebSockets and other parties to update databases other than the active one. - * @type {DatabaseInstance} - */ - serverDatabase: DatabaseInstance; - - constructor(serverDatabase?: Database) { - this.serverDatabase = serverDatabase; - } - - /** - * handleIsolatedEntity: Handler responsible for the Create/Update operations on the isolated entities as described - * by the IsolatedEntities enum - * @param {HandleIsolatedEntityArgs} entityData - * @param {IsolatedEntities} entityData.tableName - * @param {Records} entityData.values - * @returns {Promise} - */ - handleIsolatedEntity = async ({tableName, values}: HandleIsolatedEntityArgs) => { - let findMatchingRecordBy; - let fieldName; - let operator; - let rawValues; - - if (!values.length) { - throw new DataOperatorException( - `An empty "values" array has been passed to the handleIsolatedEntity method for entity ${tableName}`, - ); - } - - switch (tableName) { - case IsolatedEntities.APP: { - findMatchingRecordBy = isRecordAppEqualToRaw; - fieldName = 'version_number'; - operator = operateAppRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'versionNumber'}); - break; - } - case IsolatedEntities.CUSTOM_EMOJI: { - findMatchingRecordBy = isRecordCustomEmojiEqualToRaw; - fieldName = 'id'; - operator = operateCustomEmojiRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'id'}); - break; - } - case IsolatedEntities.GLOBAL: { - findMatchingRecordBy = isRecordGlobalEqualToRaw; - fieldName = 'name'; - operator = operateGlobalRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'name'}); - break; - } - case IsolatedEntities.ROLE: { - findMatchingRecordBy = isRecordRoleEqualToRaw; - fieldName = 'id'; - operator = operateRoleRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'id'}); - break; - } - case IsolatedEntities.SERVERS: { - findMatchingRecordBy = isRecordServerEqualToRaw; - fieldName = 'url'; - operator = operateServersRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'displayName'}); - break; - } - case IsolatedEntities.SYSTEM: { - findMatchingRecordBy = isRecordSystemEqualToRaw; - fieldName = 'id'; - operator = operateSystemRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'id'}); - break; - } - case IsolatedEntities.TERMS_OF_SERVICE: { - findMatchingRecordBy = isRecordTermsOfServiceEqualToRaw; - fieldName = 'id'; - operator = operateTermsOfServiceRecord; - rawValues = getUniqueRawsBy({raws: values, key: 'id'}); - break; - } - default: { - throw new DataOperatorException( - `handleIsolatedEntity was called with an invalid table name ${tableName}`, - ); - } - } - - if (fieldName && findMatchingRecordBy) { - await this.handleEntityRecords({ - findMatchingRecordBy, - fieldName, - operator, - rawValues, - tableName, - }); - } - }; - - /** - * handleDraft: Handler responsible for the Create/Update operations occurring the Draft entity from the 'Server' schema - * @param {RawDraft[]} drafts - * @returns {Promise} - */ - handleDraft = async (drafts: RawDraft[]) => { - if (!drafts.length) { - throw new DataOperatorException( - 'An empty "drafts" array has been passed to the handleReactions method', - ); - } - - const rawValues = getUniqueRawsBy({raws: drafts, key: 'channel_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordDraftEqualToRaw, - fieldName: 'channel_id', - operator: operateDraftRecord, - rawValues, - tableName: DRAFT, - }); - }; - - /** - * handleReactions: Handler responsible for the Create/Update operations occurring on the Reaction entity from the 'Server' schema - * @param {HandleReactionsArgs} handleReactions - * @param {RawReaction[]} handleReactions.reactions - * @param {boolean} handleReactions.prepareRowsOnly - * @throws DataOperatorException - * @returns {Promise<[] | (Reaction | CustomEmoji)[]>} - */ - handleReactions = async ({reactions, prepareRowsOnly}: HandleReactionsArgs) => { - if (!reactions.length) { - throw new DataOperatorException( - 'An empty "reactions" array has been passed to the handleReactions method', - ); - } - - const rawValues = getUniqueRawsBy({raws: reactions, key: 'emoji_name'}) as RawReaction[]; - - const database = await this.getDatabase(REACTION); - - const { - createEmojis, - createReactions, - deleteReactions, - } = await sanitizeReactions({ - database, - post_id: reactions[0].post_id, - rawReactions: rawValues, - }); - - let batchRecords: Model[] = []; - - if (createReactions.length) { - // Prepares record for model Reactions - const reactionsRecords = (await this.prepareRecords({ - createRaws: createReactions, - database, - recordOperator: operateReactionRecord, - tableName: REACTION, - })) as Reaction[]; - batchRecords = batchRecords.concat(reactionsRecords); - } - - if (createEmojis.length) { - // Prepares records for model CustomEmoji - const emojiRecords = (await this.prepareRecords({ - createRaws: getRawRecordPairs(createEmojis), - database, - recordOperator: operateCustomEmojiRecord, - tableName: CUSTOM_EMOJI, - })) as CustomEmoji[]; - batchRecords = batchRecords.concat(emojiRecords); - } - - batchRecords = batchRecords.concat(deleteReactions); - - if (prepareRowsOnly) { - return batchRecords; - } - - if (batchRecords?.length) { - await this.batchOperations({ - database, - models: batchRecords, - }); - } - - return []; - }; - - /** - * handlePosts: Handler responsible for the Create/Update operations occurring on the Post entity from the 'Server' schema - * @param {HandlePostsArgs} handlePosts - * @param {string[]} handlePosts.orders - * @param {RawPost[]} handlePosts.values - * @param {string | undefined} handlePosts.previousPostId - * @returns {Promise} - */ - handlePosts = async ({orders, values, previousPostId}: HandlePostsArgs) => { - const tableName = POST; - - // We rely on the order array; if it is empty, we stop processing - if (!orders.length) { - throw new DataOperatorException( - 'An empty "order" array has been passed to the handlePosts method', - ); - } - - const rawValues = getUniqueRawsBy({raws: values, key: 'id'}) as RawPost[]; - - // By sanitizing the values, we are separating 'posts' that needs updating ( i.e. un-ordered posts ) from those that need to be created in our database - const {postsOrdered, postsUnordered} = sanitizePosts({ - posts: rawValues, - orders, - }); - - // Here we verify in our database that the postsOrdered truly need 'CREATION' - const futureEntries = await this.processInputs({ - rawValues: postsOrdered, - tableName, - findMatchingRecordBy: isRecordPostEqualToRaw, - fieldName: 'id', - }); - - if (futureEntries.createRaws?.length) { - let batch: Model[] = []; - let files: RawFile[] = []; - const postsInThread = []; - let reactions: RawReaction[] = []; - let emojis: RawCustomEmoji[] = []; - const images: { images: Dictionary; postId: string }[] = []; - const embeds: { embed: RawEmbed[]; postId: string }[] = []; - - // We create the 'chain of posts' by linking each posts' previousId to the post before it in the order array - const linkedRawPosts: MatchExistingRecord[] = createPostsChain({ - orders, - previousPostId: previousPostId || '', - rawPosts: postsOrdered, - }); - - const database = await this.getDatabase(tableName); - - // Prepares records for batch processing onto the 'Post' entity for the server schema - const posts = (await this.prepareRecords({ - createRaws: linkedRawPosts, - database, - recordOperator: operatePostRecord, - tableName, - }))as Post[]; - - // Appends the processed records into the final batch array - batch = batch.concat(posts); - - // Starts extracting information from each post to build up for related entities' data - for (let i = 0; i < postsOrdered.length; i++) { - const post = postsOrdered[i] as RawPost; - - // PostInThread handler: checks for id === root_id , if so, then call PostsInThread operator - if (!post.root_id) { - postsInThread.push({ - earliest: post.create_at, - post_id: post.id, - }); - } - - if (post?.metadata && Object.keys(post?.metadata).length > 0) { - const metadata = post.metadata; - - // Extracts reaction from post's metadata - reactions = reactions.concat(metadata?.reactions ?? []); - - // Extracts emojis from post's metadata - emojis = emojis.concat(metadata?.emojis ?? []); - - // Extracts files from post's metadata - files = files.concat(metadata?.files ?? []); - - // Extracts images and embeds from post's metadata - if (metadata?.images) { - images.push({images: metadata.images, postId: post.id}); - } - - if (metadata?.embeds) { - embeds.push({embed: metadata.embeds, postId: post.id}); - } - } - } - - if (reactions.length) { - // calls handler for Reactions - const postReactions = (await this.handleReactions({ - reactions, - prepareRowsOnly: true, - })) as Reaction[]; - - batch = batch.concat(postReactions); - } - - if (files.length) { - // calls handler for Files - const postFiles = (await this.handleFiles({ - files, - prepareRowsOnly: true, - })) as File[]; - - batch = batch.concat(postFiles); - } - - if (images.length || embeds.length) { - // calls handler for postMetadata ( embeds and images ) - const postMetadata = (await this.handlePostMetadata({ - images, - embeds, - prepareRowsOnly: true, - })) as PostMetadata[]; - - batch = batch.concat(postMetadata); - } - - if (batch.length) { - await this.batchOperations({database, models: batch}); - } - - // LAST: calls handler for CustomEmojis, PostsInThread, PostsInChannel - if (emojis.length) { - await this.handleIsolatedEntity({tableName: IsolatedEntities.CUSTOM_EMOJI, values: emojis}); - } - - if (postsInThread.length) { - await this.handlePostsInThread(postsInThread); - } - - if (postsOrdered.length) { - await this.handlePostsInChannel(postsOrdered); - } - } - - if (postsUnordered.length) { - // Truly update those posts that have a different update_at value - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordPostEqualToRaw, - fieldName: 'id', - operator: operatePostRecord, - rawValues: postsUnordered, - tableName: POST, - }); - } - }; - - /** - * handleFiles: Handler responsible for the Create/Update operations occurring on the File entity from the 'Server' schema - * @param {HandleFilesArgs} handleFiles - * @param {RawFile[]} handleFiles.files - * @param {boolean} handleFiles.prepareRowsOnly - * @returns {Promise} - */ - private handleFiles = async ({files, prepareRowsOnly}: HandleFilesArgs) => { - if (!files.length) { - return []; - } - - const database = await this.getDatabase(FILE); - - const postFiles = (await this.prepareRecords({ - createRaws: getRawRecordPairs(files), - database, - recordOperator: operateFileRecord, - tableName: FILE, - })) as File[]; - - if (prepareRowsOnly) { - return postFiles; - } - - if (postFiles?.length) { - await this.batchOperations({database, models: [...postFiles]}); - } - - return []; - }; - - /** - * handlePostMetadata: Handler responsible for the Create/Update operations occurring on the PostMetadata entity from the 'Server' schema - * @param {HandlePostMetadataArgs} handlePostMetadata - * @param {{embed: RawEmbed[], postId: string}[] | undefined} handlePostMetadata.embeds - * @param {{images: Dictionary, postId: string}[] | undefined} handlePostMetadata.images - * @param {boolean} handlePostMetadata.prepareRowsOnly - * @returns {Promise} - */ - private handlePostMetadata = async ({embeds, images, prepareRowsOnly}: HandlePostMetadataArgs) => { - const metadata: RawPostMetadata[] = []; - - if (images?.length) { - images.forEach((image) => { - const imageEntry = Object.entries(image.images); - metadata.push({ - data: {...imageEntry?.[0]?.[1], url: imageEntry?.[0]?.[0]}, - type: 'images', - postId: image.postId, - }); - }); - } - - if (embeds?.length) { - embeds.forEach((postEmbed) => { - postEmbed.embed.forEach((embed: RawEmbed) => { - metadata.push({ - data: {...embed.data}, - type: embed.type, - postId: postEmbed.postId, - }); - }); - }); - } - - if (!metadata.length) { - return []; - } - - const database = await this.getDatabase(POST_METADATA); - - const postMetas = (await this.prepareRecords({ - createRaws: getRawRecordPairs(metadata), - database, - recordOperator: operatePostMetadataRecord, - tableName: POST_METADATA, - })) as PostMetadata[]; - - if (prepareRowsOnly) { - return postMetas; - } - - if (postMetas?.length) { - await this.batchOperations({database, models: [...postMetas]}); - } - - return []; - }; - - /** - * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread entity from the 'Server' schema - * @param {RawPostsInThread[]} rootPosts - * @returns {Promise} - */ - private handlePostsInThread = async (rootPosts: RawPostsInThread[]) => { - if (!rootPosts.length) { - return; - } - - const postIds = rootPosts.map((postThread) => postThread.post_id); - const rawPostsInThreads: RawPostsInThread[] = []; - - const database = await this.getDatabase(POSTS_IN_THREAD); - - // Retrieves all threads whereby their root_id can be one of the element in the postIds array - const threads = (await database.collections. - get(POST). - query(Q.where('root_id', Q.oneOf(postIds))). - fetch()) as Post[]; - - // The aim here is to find the last reply in that thread; hence the latest create_at value - rootPosts.forEach((rootPost) => { - const childPosts = []; - let maxCreateAt = 0; - for (let i = 0; i < threads.length; i++) { - const thread = threads[i]; - if (thread?.rootId === rootPost.post_id) { - // Creates a sub-array of threads relating to rootPost.post_id - childPosts.push(thread); - } - - // Retrieves max createAt date of all posts whose root_id is rootPost.post_id - maxCreateAt = thread.createAt > maxCreateAt ? thread.createAt : maxCreateAt; - } - - // Collects all 'raw' postInThreads objects that will be sent to the operatePostsInThread function - rawPostsInThreads.push({...rootPost, latest: maxCreateAt}); - }); - - if (rawPostsInThreads.length) { - const postInThreadRecords = (await this.prepareRecords({ - createRaws: getRawRecordPairs(rawPostsInThreads), - database, - recordOperator: operatePostInThreadRecord, - tableName: POSTS_IN_THREAD, - })) as PostsInThread[]; - - if (postInThreadRecords?.length) { - await this.batchOperations({database, models: postInThreadRecords}); - } - } - }; - - /** - * handlePostsInChannel: Handler responsible for the Create/Update operations occurring on the PostsInChannel entity from the 'Server' schema - * @param {RawPost[]} posts - * @returns {Promise} - */ - private handlePostsInChannel = async (posts: RawPost[]) => { - // At this point, the parameter 'posts' is already a chain of posts. Now, we have to figure out how to plug it - // into existing chains in the PostsInChannel table - - if (!posts.length) { - return []; - } - - // Sort a clone of 'posts' array by create_at - const sortedPosts = [...posts].sort((a, b) => { - return a.create_at - b.create_at; - }); - - // The first element (beginning of chain) - const tipOfChain: RawPost = sortedPosts[0]; - - // Channel Id for this chain of posts - const channelId = tipOfChain.channel_id; - - // Find smallest 'create_at' value in chain - const earliest = tipOfChain.create_at; - - // Find highest 'create_at' value in chain; -1 means we are dealing with one item in the posts array - const latest = sortedPosts[sortedPosts.length - 1].create_at; - - const database = await this.getDatabase(POSTS_IN_CHANNEL); - - // Find the records in the PostsInChannel table that have a matching channel_id - // const chunks = (await database.collections.get(POSTS_IN_CHANNEL).query(Q.where('channel_id', channelId)).fetch()) as PostsInChannel[]; - const chunks = (await retrieveRecords({ - database, - tableName: POSTS_IN_CHANNEL, - condition: Q.where('channel_id', channelId), - })) as PostsInChannel[]; - - const createPostsInChannelRecord = async () => { - const createPostsInChannel = {channel_id: channelId, earliest, latest}; - await this.executeInDatabase({ - createRaws: [{record: undefined, raw: createPostsInChannel}], - tableName: POSTS_IN_CHANNEL, - recordOperator: operatePostsInChannelRecord, - }); - }; - - // chunk length 0; then it's a new chunk to be added to the PostsInChannel table - if (chunks.length === 0) { - await createPostsInChannelRecord(); - return []; - } - - // Sort chunks (in-place) by earliest field ( oldest to newest ) - chunks.sort((a, b) => { - return a.earliest - b.earliest; - }); - - let found = false; - let targetChunk: PostsInChannel; - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - // find if we should plug the chain before - const chunk = chunks[chunkIndex]; - if (earliest < chunk.earliest) { - found = true; - targetChunk = chunk; - } - - if (found) { - break; - } - } - - if (found) { - // We have a potential chunk to plug nearby - const potentialPosts = await retrieveRecords({database, tableName: POST, condition: Q.where('create_at', earliest)}) as Post[]; - - if (potentialPosts?.length > 0) { - const targetPost = potentialPosts[0]; - - // now we decide if we need to operate on the targetChunk or just create a new chunk - const isChainable = tipOfChain.prev_post_id === targetPost.previousPostId; - - if (isChainable) { - // Update this chunk's data in PostsInChannel table. earliest comes from tipOfChain while latest comes from chunk - await database.action(async () => { - await targetChunk.update((postInChannel) => { - postInChannel.earliest = earliest; - }); - }); - } else { - await createPostsInChannelRecord(); - return []; - } - } - } else { - await createPostsInChannelRecord(); - return []; - } - - return []; - }; - - /** - * handleUsers: Handler responsible for the Create/Update operations occurring on the User entity from the 'Server' schema - * @param {RawUser[]} users - * @throws DataOperatorException - * @returns {Promise} - */ - handleUsers = async (users: RawUser[]) => { - if (!users.length) { - throw new DataOperatorException( - 'An empty "users" array has been passed to the handleUsers method', - ); - } - - const rawValues = getUniqueRawsBy({raws: users, key: 'id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordUserEqualToRaw, - fieldName: 'id', - operator: operateUserRecord, - rawValues, - tableName: USER, - }); - }; - - /** - * handlePreferences: Handler responsible for the Create/Update operations occurring on the PREFERENCE entity from the 'Server' schema - * @param {RawPreference[]} preferences - * @throws DataOperatorException - * @returns {Promise} - */ - handlePreferences = async (preferences: RawPreference[]) => { - if (!preferences.length) { - throw new DataOperatorException( - 'An empty "preferences" array has been passed to the handlePreferences method', - ); - } - - const rawValues = getUniqueRawsBy({raws: preferences, key: 'name'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordPreferenceEqualToRaw, - fieldName: 'user_id', - operator: operatePreferenceRecord, - rawValues, - tableName: PREFERENCE, - }); - }; - - /** - * handleTeamMemberships: Handler responsible for the Create/Update operations occurring on the TEAM_MEMBERSHIP entity from the 'Server' schema - * @param {RawTeamMembership[]} teamMemberships - * @throws DataOperatorException - * @returns {Promise} - */ - handleTeamMemberships = async (teamMemberships: RawTeamMembership[]) => { - if (!teamMemberships.length) { - throw new DataOperatorException( - 'An empty "teamMemberships" array has been passed to the handleTeamMemberships method', - ); - } - - const rawValues = getUniqueRawsBy({raws: teamMemberships, key: 'team_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordTeamMembershipEqualToRaw, - fieldName: 'user_id', - operator: operateTeamMembershipRecord, - rawValues, - tableName: TEAM_MEMBERSHIP, - }); - }; - - /** - * handleGroupMembership: Handler responsible for the Create/Update operations occurring on the GROUP_MEMBERSHIP entity from the 'Server' schema - * @param {RawGroupMembership[]} groupMemberships - * @throws DataOperatorException - * @returns {Promise} - */ - handleGroupMembership = async (groupMemberships: RawGroupMembership[]) => { - if (!groupMemberships.length) { - throw new DataOperatorException( - 'An empty "groupMemberships" array has been passed to the handleGroupMembership method', - ); - } - - const rawValues = getUniqueRawsBy({raws: groupMemberships, key: 'group_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordGroupMembershipEqualToRaw, - fieldName: 'user_id', - operator: operateGroupMembershipRecord, - rawValues, - tableName: GROUP_MEMBERSHIP, - }); - }; - - /** - * handleChannelMembership: Handler responsible for the Create/Update operations occurring on the CHANNEL_MEMBERSHIP entity from the 'Server' schema - * @param {RawChannelMembership[]} channelMemberships - * @throws DataOperatorException - * @returns {Promise} - */ - handleChannelMembership = async (channelMemberships: RawChannelMembership[]) => { - if (!channelMemberships.length) { - throw new DataOperatorException( - 'An empty "channelMemberships" array has been passed to the handleChannelMembership method', - ); - } - - const rawValues = getUniqueRawsBy({raws: channelMemberships, key: 'channel_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordChannelMembershipEqualToRaw, - fieldName: 'user_id', - operator: operateChannelMembershipRecord, - rawValues, - tableName: CHANNEL_MEMBERSHIP, - }); - }; - - /** - * handleGroup: Handler responsible for the Create/Update operations occurring on the GROUP entity from the 'Server' schema - * @param {RawGroup[]} groups - * @throws DataOperatorException - * @returns {Promise} - */ - handleGroup = async (groups: RawGroup[]) => { - if (!groups.length) { - throw new DataOperatorException( - 'An empty "groups" array has been passed to the handleGroup method', - ); - } - - const rawValues = getUniqueRawsBy({raws: groups, key: 'name'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordGroupEqualToRaw, - fieldName: 'name', - operator: operateGroupRecord, - rawValues, - tableName: GROUP, - }); - }; - - /** - * handleGroupsInTeam: Handler responsible for the Create/Update operations occurring on the GROUPS_IN_TEAM entity from the 'Server' schema - * @param {RawGroupsInTeam[]} groupsInTeams - * @throws DataOperatorException - * @returns {Promise} - */ - handleGroupsInTeam = async (groupsInTeams: RawGroupsInTeam[]) => { - if (!groupsInTeams.length) { - throw new DataOperatorException( - 'An empty "groups" array has been passed to the handleGroupsInTeam method', - ); - } - - const rawValues = getUniqueRawsBy({raws: groupsInTeams, key: 'group_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordGroupsInTeamEqualToRaw, - fieldName: 'group_id', - operator: operateGroupsInTeamRecord, - rawValues, - tableName: GROUPS_IN_TEAM, - }); - }; - - /** - * handleGroupsInChannel: Handler responsible for the Create/Update operations occurring on the GROUPS_IN_CHANNEL entity from the 'Server' schema - * @param {RawGroupsInChannel[]} groupsInChannels - * @throws DataOperatorException - * @returns {Promise} - */ - handleGroupsInChannel = async (groupsInChannels: RawGroupsInChannel[]) => { - if (!groupsInChannels.length) { - throw new DataOperatorException( - 'An empty "groups" array has been passed to the handleGroupsInTeam method', - ); - } - - const rawValues = getUniqueRawsBy({raws: groupsInChannels, key: 'channel_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordGroupsInChannelEqualToRaw, - fieldName: 'group_id', - operator: operateGroupsInChannelRecord, - rawValues, - tableName: GROUPS_IN_CHANNEL, - }); - }; - - /** - * handleTeam: Handler responsible for the Create/Update operations occurring on the TEAM entity from the 'Server' schema - * @param {RawTeam[]} teams - * @throws DataOperatorException - * @returns {Promise} - */ - handleTeam = async (teams: RawTeam[]) => { - if (!teams.length) { - throw new DataOperatorException( - 'An empty "teams" array has been passed to the handleTeam method', - ); - } - - const rawValues = getUniqueRawsBy({raws: teams, key: 'id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordTeamEqualToRaw, - fieldName: 'id', - operator: operateTeamRecord, - rawValues, - tableName: TEAM, - }); - }; - - /** - * handleTeamChannelHistory: Handler responsible for the Create/Update operations occurring on the TEAM_CHANNEL_HISTORY entity from the 'Server' schema - * @param {RawTeamChannelHistory[]} teamChannelHistories - * @throws DataOperatorException - * @returns {Promise} - */ - handleTeamChannelHistory = async (teamChannelHistories: RawTeamChannelHistory[]) => { - if (!teamChannelHistories.length) { - throw new DataOperatorException( - 'An empty "teamChannelHistories" array has been passed to the handleTeamChannelHistory method', - ); - } - - const rawValues = getUniqueRawsBy({raws: teamChannelHistories, key: 'team_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordTeamChannelHistoryEqualToRaw, - fieldName: 'team_id', - operator: operateTeamChannelHistoryRecord, - rawValues, - tableName: TEAM_CHANNEL_HISTORY, - }); - }; - - /** - * handleTeamSearchHistory: Handler responsible for the Create/Update operations occurring on the TEAM_SEARCH_HISTORY entity from the 'Server' schema - * @param {RawTeamSearchHistory[]} teamSearchHistories - * @throws DataOperatorException - * @returns {Promise} - */ - handleTeamSearchHistory = async (teamSearchHistories: RawTeamSearchHistory[]) => { - if (!teamSearchHistories.length) { - throw new DataOperatorException( - 'An empty "teamSearchHistories" array has been passed to the handleTeamSearchHistory method', - ); - } - - const rawValues = getUniqueRawsBy({raws: teamSearchHistories, key: 'term'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordTeamSearchHistoryEqualToRaw, - fieldName: 'team_id', - operator: operateTeamSearchHistoryRecord, - rawValues, - tableName: TEAM_SEARCH_HISTORY, - }); - }; - - /** - * handleSlashCommand: Handler responsible for the Create/Update operations occurring on the SLASH_COMMAND entity from the 'Server' schema - * @param {RawSlashCommand[]} slashCommands - * @throws DataOperatorException - * @returns {Promise} - */ - handleSlashCommand = async (slashCommands: RawSlashCommand[]) => { - if (!slashCommands.length) { - throw new DataOperatorException( - 'An empty "slashCommands" array has been passed to the handleSlashCommand method', - ); - } - - const rawValues = getUniqueRawsBy({raws: slashCommands, key: 'id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordSlashCommandEqualToRaw, - fieldName: 'id', - operator: operateSlashCommandRecord, - rawValues, - tableName: SLASH_COMMAND, - }); - }; - - /** - * handleMyTeam: Handler responsible for the Create/Update operations occurring on the MY_TEAM entity from the 'Server' schema - * @param {RawMyTeam[]} myTeams - * @throws DataOperatorException - * @returns {Promise} - */ - handleMyTeam = async (myTeams: RawMyTeam[]) => { - if (!myTeams.length) { - throw new DataOperatorException( - 'An empty "myTeams" array has been passed to the handleSlashCommand method', - ); - } - - const rawValues = getUniqueRawsBy({raws: myTeams, key: 'team_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordMyTeamEqualToRaw, - fieldName: 'team_id', - operator: operateMyTeamRecord, - rawValues, - tableName: MY_TEAM, - }); - }; - - /** - * handleChannel: Handler responsible for the Create/Update operations occurring on the CHANNEL entity from the 'Server' schema - * @param {RawChannel[]} channels - * @throws DataOperatorException - * @returns {Promise} - */ - handleChannel = async (channels: RawChannel[]) => { - if (!channels.length) { - throw new DataOperatorException( - 'An empty "channels" array has been passed to the handleChannel method', - ); - } - - const rawValues = getUniqueRawsBy({raws: channels, key: 'id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordChannelEqualToRaw, - fieldName: 'id', - operator: operateChannelRecord, - rawValues, - tableName: CHANNEL, - }); - }; - - /** - * handleMyChannelSettings: Handler responsible for the Create/Update operations occurring on the MY_CHANNEL_SETTINGS entity from the 'Server' schema - * @param {RawMyChannelSettings[]} settings - * @throws DataOperatorException - * @returns {Promise} - */ - handleMyChannelSettings = async (settings: RawMyChannelSettings[]) => { - if (!settings.length) { - throw new DataOperatorException( - 'An empty "settings" array has been passed to the handleMyChannelSettings method', - ); - } - - const rawValues = getUniqueRawsBy({raws: settings, key: 'channel_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordMyChannelSettingsEqualToRaw, - fieldName: 'channel_id', - operator: operateMyChannelSettingsRecord, - rawValues, - tableName: MY_CHANNEL_SETTINGS, - }); - }; - - /** - * handleChannelInfo: Handler responsible for the Create/Update operations occurring on the CHANNEL_INFO entity from the 'Server' schema - * @param {RawChannelInfo[]} channelInfos - * @throws DataOperatorException - * @returns {Promise} - */ - handleChannelInfo = async (channelInfos: RawChannelInfo[]) => { - if (!channelInfos.length) { - throw new DataOperatorException( - 'An empty "channelInfos" array has been passed to the handleMyChannelSettings method', - ); - } - - const rawValues = getUniqueRawsBy({raws: channelInfos, key: 'channel_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordChannelInfoEqualToRaw, - fieldName: 'channel_id', - operator: operateChannelInfoRecord, - rawValues, - tableName: CHANNEL_INFO, - }); - }; - - /** - * handleMyChannel: Handler responsible for the Create/Update operations occurring on the MY_CHANNEL entity from the 'Server' schema - * @param {RawMyChannel[]} myChannels - * @throws DataOperatorException - * @returns {Promise} - */ - handleMyChannel = async (myChannels: RawMyChannel[]) => { - if (!myChannels.length) { - throw new DataOperatorException( - 'An empty "myChannels" array has been passed to the handleMyChannel method', - ); - } - - const rawValues = getUniqueRawsBy({raws: myChannels, key: 'channel_id'}); - - await this.handleEntityRecords({ - findMatchingRecordBy: isRecordMyChannelEqualToRaw, - fieldName: 'channel_id', - operator: operateMyChannelRecord, - rawValues, - tableName: MY_CHANNEL, - }); - }; - - /** - * handleEntityRecords : Utility that processes some entities' data against values already present in the database so as to avoid duplicity. - * @param {HandleEntityRecordsArgs} handleEntityArgs - * @param {(existing: Model, newElement: RawValue) => boolean} handleEntityArgs.findMatchingRecordBy - * @param {string} handleEntityArgs.fieldName - * @param {(DataFactoryArgs) => Promise} handleEntityArgs.operator - * @param {RawValue[]} handleEntityArgs.rawValues - * @param {string} handleEntityArgs.tableName - * @returns {Promise} - */ - private handleEntityRecords = async ({findMatchingRecordBy, fieldName, operator, rawValues, tableName}: HandleEntityRecordsArgs) => { - if (!rawValues.length) { - return null; - } - - const {createRaws, updateRaws} = await this.processInputs({ - rawValues, - tableName, - findMatchingRecordBy, - fieldName, - }); - - const records = await this.executeInDatabase({ - recordOperator: operator, - tableName, - createRaws, - updateRaws, - }); - - return records; - }; - - /** - * processInputs: This method weeds out duplicates entries. It may happen that we do multiple inserts for - * the same value. Hence, prior to that we query the database and pick only those values that are 'new' from the 'Raw' array. - * @param {ProcessInputsArgs} inputsArg - * @param {RawValue[]} inputsArg.rawValues - * @param {string} inputsArg.tableName - * @param {string} inputsArg.fieldName - * @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.findMatchingRecordBy - */ - private processInputs = async ({rawValues, tableName, findMatchingRecordBy, fieldName}: ProcessInputsArgs) => { - // We will query an entity where one of its fields can match a range of values. Hence, here we are extracting all those potential values. - const columnValues: string[] = getRangeOfValues({fieldName, raws: rawValues}); - - const database = await this.getDatabase(tableName); - - const existingRecords = (await retrieveRecords({ - database, - tableName, - condition: Q.where(fieldName, Q.oneOf(columnValues)), - })) as Model[]; - - const createRaws: MatchExistingRecord[] = []; - const updateRaws: MatchExistingRecord[] = []; - - if (existingRecords.length > 0) { - rawValues.map((newElement: RawValue) => { - const findIndex = existingRecords.findIndex((existing) => { - return findMatchingRecordBy(existing, newElement); - }); - - // We found a record in the database that matches this element; hence, we'll proceed for an UPDATE operation - if (findIndex > -1) { - const existingRecord = existingRecords[findIndex]; - - // Some raw value has an update_at field. We'll proceed to update only if the update_at value is different from the record's value in database - const isUpdateAtSimilar = hasSimilarUpdateAt({ - tableName, - existingRecord, - newValue: newElement, - }); - - if (!isUpdateAtSimilar) { - return updateRaws.push({ - record: existingRecord, - raw: newElement, - }); - } - } else { - // This RawValue is not present in the database; hence, we need to create it - return createRaws.push({record: undefined, raw: newElement}); - } - return null; - }); - - return { - createRaws, - updateRaws, - }; - } - - return { - createRaws: getRawRecordPairs(rawValues), - updateRaws, - }; - }; - - /** - * batchOperations: Accepts an instance of Database (either Default or Server) and an array of - * prepareCreate/prepareUpdate 'models' and executes the actions on the database. - * @param {BatchOperationsArgs} operation - * @param {Database} operation.database - * @param {Array} operation.models - * @throws {DataOperatorException} - * @returns {Promise} - */ - private batchOperations = async ({database, models}: BatchOperationsArgs) => { - try { - if (models.length > 0) { - await database.action(async () => { - await database.batch(...models); - }); - } - } catch (e) { - throw new DataOperatorException('batchOperations error ', e); - } - }; - - /** - * prepareRecords: Utility method that actually calls the operators for the handlers - * @param {PrepareRecordsArgs} prepareRecord - * @param {Database} prepareRecord.database - * @param {string} prepareRecord.tableName - * @param {RawValue[]} prepareRecord.createRaws - * @param {RawValue[]} prepareRecord.updateRaws - * @param {(DataFactoryArgs) => void;} prepareRecord.recordOperator - * @throws {DataOperatorException} - * @returns {Promise} - */ - private prepareRecords = async ({database, tableName, createRaws, updateRaws, recordOperator}: PrepareRecordsArgs) => { - if (!database) { - throw new DataOperatorException( - 'prepareRecords accepts only rawPosts of type RawValue[] or valid database connection', - ); - } - - let prepareCreate: Model[] = []; - let prepareUpdate: Model[] = []; - - // create operation - if (createRaws?.length) { - const recordPromises = await createRaws.map( - async (createRecord: MatchExistingRecord) => { - const record = await recordOperator({database, tableName, value: createRecord, action: OperationType.CREATE}); - return record; - }, - ); - - const results = ((await Promise.all(recordPromises)) as unknown) as Model[]; - prepareCreate = prepareCreate.concat(results); - } - - // update operation - if (updateRaws?.length) { - const recordPromises = await updateRaws.map( - async (updateRecord: MatchExistingRecord) => { - const record = await recordOperator({database, tableName, value: updateRecord, action: OperationType.UPDATE}); - return record; - }, - ); - - const results = ((await Promise.all(recordPromises)) as unknown) as Model[]; - prepareUpdate = prepareUpdate.concat(results); - } - - return [...prepareCreate, ...prepareUpdate]; - }; - - /** - * executeInDatabase: Handles the Create/Update operations on an entity. - * @param {PrepareForDatabaseArgs} executeInDatabase - * @param {string} executeInDatabase.tableName - * @param {RecordValue[]} executeInDatabase.createRaws - * @param {RecordValue[]} executeInDatabase.updateRaws - * @param {(DataFactoryArgs) => void} executeInDatabase.recordOperator - * @returns {Promise} - */ - private executeInDatabase = async ({createRaws, recordOperator, tableName, updateRaws}: PrepareForDatabaseArgs) => { - const database = await this.getDatabase(tableName); - - const models = (await this.prepareRecords({ - database, - tableName, - createRaws, - updateRaws, - recordOperator, - })) as Model[]; - - if (models?.length > 0) { - await this.batchOperations({database, models}); - } - }; - - /** - * getDatabase: Based on the table's name, it will return a database instance either from the 'DEFAULT' database or - * the 'SERVER' database - * @param {string} tableName - * @returns {Promise} - */ - private getDatabase = async (tableName: string) => { - const isDefaultConnection = Object.values(MM_TABLES.DEFAULT).some( - (tbName) => { - return tableName === tbName; - }, - ); - - const promise = isDefaultConnection ? this.getDefaultDatabase : this.getServerDatabase; - const connection = await promise(); - - return connection; - }; - - /** - * getDefaultDatabase: Returns the default database - * @throws {DatabaseConnectionException} - * @returns {Promise} - */ - private getDefaultDatabase = async () => { - const connection = await DatabaseManager.getDefaultDatabase(); - if (connection === undefined) { - throw new DatabaseConnectionException( - 'An error occurred while retrieving the default database', - '', - ); - } - return connection; - }; - - /** - * getServerDatabase: Returns the current active server database (multi-server support) - * @throws {DatabaseConnectionException} - * @returns {Promise} - */ - private getServerDatabase = async () => { - // Third parties trying to update the database - if (this.serverDatabase) { - return this.serverDatabase; - } - - // NOTE: here we are getting the active server directly as in a multi-server support system, the current - // active server connection will already be set on application init - const connection = await DatabaseManager.getActiveServerDatabase(); - if (connection === undefined) { - throw new DatabaseConnectionException( - 'An error occurred while retrieving the server database', - '', - ); - } - return connection; - }; -} - -export default DataOperator; diff --git a/app/database/admin/data_operator/handlers/test.ts b/app/database/admin/data_operator/handlers/test.ts deleted file mode 100644 index 470af97d30..0000000000 --- a/app/database/admin/data_operator/handlers/test.ts +++ /dev/null @@ -1,1616 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import DataOperator from '@database/admin/data_operator'; -import { - isRecordAppEqualToRaw, - isRecordDraftEqualToRaw, - isRecordGlobalEqualToRaw, - isRecordRoleEqualToRaw, - isRecordServerEqualToRaw, - isRecordSystemEqualToRaw, - isRecordTermsOfServiceEqualToRaw, -} from '@database/admin/data_operator/comparators'; -import DatabaseManager from '@database/admin/database_manager'; -import DataOperatorException from '@database/admin/exceptions/data_operator_exception'; -import {RawApp, RawGlobal, RawRole, RawServers, RawTermsOfService} from '@typings/database/database'; -import {DatabaseType, IsolatedEntities} from '@typings/database/enums'; - -import { - operateAppRecord, - operateChannelInfoRecord, - operateChannelMembershipRecord, - operateChannelRecord, - operateCustomEmojiRecord, - operateDraftRecord, - operateGlobalRecord, - operateGroupMembershipRecord, - operateGroupRecord, - operateGroupsInChannelRecord, - operateGroupsInTeamRecord, - operateMyChannelRecord, - operateMyChannelSettingsRecord, - operateMyTeamRecord, - operatePreferenceRecord, - operateRoleRecord, - operateServersRecord, - operateSlashCommandRecord, - operateSystemRecord, - operateTeamChannelHistoryRecord, - operateTeamMembershipRecord, - operateTeamRecord, - operateTeamSearchHistoryRecord, - operateTermsOfServiceRecord, - operateUserRecord, -} from '../operators'; - -jest.mock('@database/admin/database_manager'); - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -describe('*** DataOperator: Handlers tests ***', () => { - const createConnection = async (setActive = false) => { - const dbName = 'server_schema_connection'; - const serverUrl = 'https://appv2.mattermost.com'; - const database = await DatabaseManager.createDatabaseConnection({ - shouldAddToDefaultDatabase: true, - configs: { - actionsEnabled: true, - dbName, - dbType: DatabaseType.SERVER, - serverUrl, - }, - }); - - if (setActive) { - await DatabaseManager.setActiveServerDatabase({ - displayName: dbName, - serverUrl, - }); - } - - return database; - }; - - it('=> HandleApp: should write to APP entity', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - - const values: RawApp[] = [ - { - build_number: 'build-10x', - created_at: 1, - version_number: 'version-10', - }, - { - build_number: 'build-11y', - created_at: 1, - version_number: 'version-11', - }, - ]; - - await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.APP, values}); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - fieldName: 'version_number', - operator: operateAppRecord, - findMatchingRecordBy: isRecordAppEqualToRaw, - rawValues: [ - { - build_number: 'build-11y', - created_at: 1, - version_number: 'version-11', - }, - ], - tableName: 'app', - }); - }); - - it('=> HandleGlobal: should write to GLOBAL entity', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const values: RawGlobal[] = [{name: 'global-1-name', value: 'global-1-value'}]; - - await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.GLOBAL, values}); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - findMatchingRecordBy: isRecordGlobalEqualToRaw, - fieldName: 'name', - operator: operateGlobalRecord, - rawValues: values, - tableName: 'global', - }); - }); - - it('=> HandleServers: should write to SERVERS entity', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const values: RawServers[] = [ - { - db_path: 'server.db', - display_name: 'community', - mention_count: 0, - unread_count: 0, - url: 'https://community.mattermost.com', - }, - ]; - await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.SERVERS, values}); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - fieldName: 'url', - operator: operateServersRecord, - findMatchingRecordBy: isRecordServerEqualToRaw, - rawValues: [ - { - db_path: 'server.db', - display_name: 'community', - mention_count: 0, - unread_count: 0, - url: 'https://community.mattermost.com', - }, - ], - tableName: 'servers', - }); - }); - - it('=> HandleRole: should write to ROLE entity', async () => { - expect.assertions(2); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const values: RawRole[] = [ - { - id: 'custom-emoji-id-1', - name: 'custom-emoji-1', - permissions: ['custom-emoji-1'], - }, - ]; - - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.ROLE, - values, - }); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - fieldName: 'id', - operator: operateRoleRecord, - findMatchingRecordBy: isRecordRoleEqualToRaw, - rawValues: [ - { - id: 'custom-emoji-id-1', - name: 'custom-emoji-1', - permissions: ['custom-emoji-1'], - }, - ], - tableName: 'Role', - }); - }); - - it('=> HandleSystem: should write to SYSTEM entity', async () => { - expect.assertions(2); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const values = [{id: 'system-id-1', name: 'system-1', value: 'system-1'}]; - await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.SYSTEM, values}); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - findMatchingRecordBy: isRecordSystemEqualToRaw, - fieldName: 'id', - operator: operateSystemRecord, - rawValues: values, - tableName: 'System', - }); - }); - - it('=> HandleTermsOfService: should write to TERMS_OF_SERVICE entity', async () => { - expect.assertions(2); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - - const values: RawTermsOfService[] = [ - { - id: 'tos-1', - accepted_at: 1, - create_at: 1613667352029, - user_id: 'user1613667352029', - text: '', - }, - ]; - - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.TERMS_OF_SERVICE, - values, - }); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - findMatchingRecordBy: isRecordTermsOfServiceEqualToRaw, - fieldName: 'id', - operator: operateTermsOfServiceRecord, - rawValues: values, - tableName: 'TermsOfService', - }); - }); - - it('=> No table name: should not call executeInDatabase if tableName is invalid', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - await expect( - DataOperator.handleIsolatedEntity({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - tableName: 'INVALID_TABLE_NAME', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - values: [{id: 'tos-1', accepted_at: 1}], - }), - ).rejects.toThrow(DataOperatorException); - }); - - it('=> HandleReactions: should write to both Reactions and CustomEmoji entities', async () => { - expect.assertions(3); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnPrepareRecords = jest.spyOn(DataOperator as any, 'prepareRecords'); - const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); - - await DataOperator.handleReactions({ - reactions: [ - { - create_at: 1608263728086, - delete_at: 0, - emoji_name: 'p4p1', - post_id: '4r9jmr7eqt8dxq3f9woypzurry', - update_at: 1608263728077, - user_id: 'ooumoqgq3bfiijzwbn8badznwc', - }, - ], - prepareRowsOnly: false, - }); - - // Called twice: Once for Reaction record and once for CustomEmoji record - expect(spyOnPrepareRecords).toHaveBeenCalledTimes(2); - - // Only one batch operation for both entities - expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); - }); - - it('=> HandleDraft: should write to the Draft entity', async () => { - expect.assertions(2); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const values = [ - { - channel_id: '4r9jmr7eqt8dxq3f9woypzurrychannelid', - files: [ - { - user_id: 'user_id', - post_id: 'post_id', - create_at: 123, - update_at: 456, - delete_at: 789, - name: 'an_image', - extension: 'jpg', - size: 10, - mime_type: 'image', - width: 10, - height: 10, - has_preview_image: false, - clientId: 'clientId', - }, - ], - message: 'test draft message for post', - root_id: '', - }, - ]; - - await DataOperator.handleDraft(values); - - expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ - findMatchingRecordBy: isRecordDraftEqualToRaw, - fieldName: 'channel_id', - operator: operateDraftRecord, - rawValues: values, - tableName: 'Draft', - }); - }); - - it('=> HandleFiles: should write to File entity', async () => { - expect.assertions(3); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnPrepareRecords = jest.spyOn(DataOperator as any, 'prepareRecords'); - const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await DataOperator.handleFiles({ - files: [ - { - user_id: 'user_id', - post_id: 'post_id', - create_at: 12345, - update_at: 456, - delete_at: 789, - name: 'an_image', - extension: 'jpg', - size: 10, - mime_type: 'image', - width: 10, - height: 10, - has_preview_image: false, - }, - ], - prepareRowsOnly: false, - }); - - expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); - expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); - }); - - it('=> HandlePosts: should write to Post and its sub-child entities', async () => { - expect.assertions(12); - - const posts = [ - { - id: '8swgtrrdiff89jnsiwiip3y1eoe', - create_at: 1596032651747, - update_at: 1596032651747, - edit_at: 0, - delete_at: 0, - is_pinned: false, - user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', - channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', - root_id: '', - parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', - original_id: '', - message: "I'll second these kudos! Thanks m!", - type: '', - props: {}, - hashtags: '', - pending_post_id: '', - reply_count: 4, - last_reply_at: 0, - participants: null, - metadata: { - images: { - 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { - width: 400, - height: 400, - format: 'png', - frame_count: 0, - }, - }, - reactions: [ - { - user_id: 'njic1w1k5inefp848jwk6oukio', - post_id: 'a7ebyw883trm884p1qcgt8yw4a', - emoji_name: 'clap', - create_at: 1608252965442, - update_at: 1608252965442, - delete_at: 0, - }, - ], - embeds: [ - { - type: 'opengraph', - url: - 'https://github.com/mickmister/mattermost-plugin-default-theme', - data: { - type: 'object', - url: - 'https://github.com/mickmister/mattermost-plugin-default-theme', - title: 'mickmister/mattermost-plugin-default-theme', - description: - 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', - determiner: '', - site_name: 'GitHub', - locale: '', - locales_alternate: null, - images: [ - { - url: '', - secure_url: - 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', - type: '', - width: 0, - height: 0, - }, - ], - audios: null, - videos: null, - }, - }, - ], - emojis: [ - { - id: 'dgwyadacdbbwjc8t357h6hwsrh', - create_at: 1502389307432, - update_at: 1502389307432, - delete_at: 0, - creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', - name: 'thanks', - }, - ], - files: [ - { - id: 'f1oxe5rtepfs7n3zifb4sso7po', - user_id: '89ertha8xpfsumpucqppy5knao', - post_id: 'a7ebyw883trm884p1qcgt8yw4a', - create_at: 1608270920357, - update_at: 1608270920357, - delete_at: 0, - name: '4qtwrg.jpg', - extension: 'jpg', - size: 89208, - mime_type: 'image/jpeg', - width: 500, - height: 656, - has_preview_image: true, - mini_preview: - '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', - }, - ], - }, - }, - { - id: '8fcnk3p1jt8mmkaprgajoxz115a', - create_at: 1596104683748, - update_at: 1596104683748, - edit_at: 0, - delete_at: 0, - is_pinned: false, - user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', - channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', - root_id: '8swgtrrdiff89jnsiwiip3y1eoe', - parent_id: '', - original_id: '', - message: 'a added to the channel by j.', - type: 'system_add_to_channel', - props: { - addedUserId: 'z89qsntet7bimd3xddfu7u9ncdaxc', - addedUsername: 'a', - userId: 'hy5sdfdfq51sebfh58ktrce5ijtcwy', - username: 'j', - }, - hashtags: '', - pending_post_id: '', - reply_count: 0, - last_reply_at: 0, - participants: null, - metadata: {}, - }, - { - id: '3y3w3a6gkbg73bnj3xund9o5ic', - create_at: 1596277483749, - update_at: 1596277483749, - edit_at: 0, - delete_at: 0, - is_pinned: false, - user_id: '44ud4m9tqwby3mphzzdwm7h31sr', - channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', - root_id: '8swgtrrdiff89jnsiwiip3y1eoe', - parent_id: 'ps81iqbwesfby8jayz7owg4yypo', - original_id: '', - message: 'Great work M!', - type: '', - props: {}, - hashtags: '', - pending_post_id: '', - reply_count: 4, - last_reply_at: 0, - participants: null, - metadata: {}, - }, - ]; - - const spyOnHandleFiles = jest.spyOn(DataOperator as any, 'handleFiles'); - const spyOnHandlePostMetadata = jest.spyOn(DataOperator as any, 'handlePostMetadata'); - const spyOnHandleReactions = jest.spyOn(DataOperator as any, 'handleReactions'); - const spyOnHandleCustomEmojis = jest.spyOn(DataOperator as any, 'handleIsolatedEntity'); - const spyOnHandlePostsInThread = jest.spyOn(DataOperator as any, 'handlePostsInThread'); - const spyOnHandlePostsInChannel = jest.spyOn(DataOperator as any, 'handlePostsInChannel'); - - await createConnection(true); - - // handlePosts will in turn call handlePostsInThread - await DataOperator.handlePosts({ - orders: [ - '8swgtrrdiff89jnsiwiip3y1eoe', - '8fcnk3p1jt8mmkaprgajoxz115a', - '3y3w3a6gkbg73bnj3xund9o5ic', - ], - values: posts, - previousPostId: '', - }); - - expect(spyOnHandleReactions).toHaveBeenCalledTimes(1); - expect(spyOnHandleReactions).toHaveBeenCalledWith({ - reactions: [ - { - user_id: 'njic1w1k5inefp848jwk6oukio', - post_id: 'a7ebyw883trm884p1qcgt8yw4a', - emoji_name: 'clap', - create_at: 1608252965442, - update_at: 1608252965442, - delete_at: 0, - }, - ], - prepareRowsOnly: true, - }); - - expect(spyOnHandleFiles).toHaveBeenCalledTimes(1); - expect(spyOnHandleFiles).toHaveBeenCalledWith({ - files: [ - { - id: 'f1oxe5rtepfs7n3zifb4sso7po', - user_id: '89ertha8xpfsumpucqppy5knao', - post_id: 'a7ebyw883trm884p1qcgt8yw4a', - create_at: 1608270920357, - update_at: 1608270920357, - delete_at: 0, - name: '4qtwrg.jpg', - extension: 'jpg', - size: 89208, - mime_type: 'image/jpeg', - width: 500, - height: 656, - has_preview_image: true, - mini_preview: - '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', - }, - ], - prepareRowsOnly: true, - }); - - expect(spyOnHandlePostMetadata).toHaveBeenCalledTimes(1); - expect(spyOnHandlePostMetadata).toHaveBeenCalledWith({ - embeds: [ - { - embed: [ - { - type: 'opengraph', - url: 'https://github.com/mickmister/mattermost-plugin-default-theme', - data: { - type: 'object', - url: 'https://github.com/mickmister/mattermost-plugin-default-theme', - title: 'mickmister/mattermost-plugin-default-theme', - description: 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', - determiner: '', - site_name: 'GitHub', - locale: '', - locales_alternate: null, - images: [ - { - url: '', - secure_url: 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', - type: '', - width: 0, - height: 0, - }, - ], - audios: null, - videos: null, - }, - }, - ], - postId: '8swgtrrdiff89jnsiwiip3y1eoe', - }, - ], - images: [ - { - images: { - 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { - width: 400, - height: 400, - format: 'png', - frame_count: 0, - }, - }, - postId: '8swgtrrdiff89jnsiwiip3y1eoe', - }, - ], - prepareRowsOnly: true, - }); - - expect(spyOnHandleCustomEmojis).toHaveBeenCalledTimes(1); - expect(spyOnHandleCustomEmojis).toHaveBeenCalledWith({ - tableName: 'CustomEmoji', - values: [ - { - id: 'dgwyadacdbbwjc8t357h6hwsrh', - create_at: 1502389307432, - update_at: 1502389307432, - delete_at: 0, - creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', - name: 'thanks', - }, - ], - }); - - expect(spyOnHandlePostsInThread).toHaveBeenCalledTimes(1); - expect(spyOnHandlePostsInThread).toHaveBeenCalledWith([ - {earliest: 1596032651747, post_id: '8swgtrrdiff89jnsiwiip3y1eoe'}, - ]); - - expect(spyOnHandlePostsInChannel).toHaveBeenCalledTimes(1); - expect(spyOnHandlePostsInChannel).toHaveBeenCalledWith(posts.slice(0, 3)); - }); - - it('=> HandleUsers: should write to User entity', async () => { - expect.assertions(2); - - const users = [ - { - id: '9ciscaqbrpd6d8s68k76xb9bte', - create_at: 1599457495881, - update_at: 1607683720173, - delete_at: 0, - username: 'a.l', - auth_service: 'saml', - email: 'a.l@mattermost.com', - email_verified: true, - is_bot: false, - nickname: '', - first_name: 'A', - last_name: 'L', - position: 'Mobile Engineer', - roles: 'system_user', - props: {}, - notify_props: { - desktop: 'all', - desktop_sound: true, - email: true, - first_name: true, - mention_keys: '', - push: 'mention', - channel: true, - auto_responder_active: false, - auto_responder_message: - 'Hello, I am out of office and unable to respond to messages.', - comments: 'never', - desktop_notification_sound: 'Hello', - push_status: 'online', - }, - last_password_update: 1604323112537, - last_picture_update: 1604686302260, - locale: 'en', - timezone: { - automaticTimezone: 'Indian/Mauritius', - manualTimezone: '', - useAutomaticTimezone: true, - }, - }, - ]; - - const spyOnExecuteInDatabase = jest.spyOn( - DataOperator as any, - 'executeInDatabase', - ); - - await createConnection(true); - - await DataOperator.handleUsers(users); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - id: '9ciscaqbrpd6d8s68k76xb9bte', - create_at: 1599457495881, - update_at: 1607683720173, - delete_at: 0, - username: 'a.l', - auth_service: 'saml', - email: 'a.l@mattermost.com', - email_verified: true, - is_bot: false, - nickname: '', - first_name: 'A', - last_name: 'L', - position: 'Mobile Engineer', - roles: 'system_user', - props: {}, - notify_props: { - desktop: 'all', - desktop_sound: true, - email: true, - first_name: true, - mention_keys: '', - push: 'mention', - channel: true, - auto_responder_active: false, - auto_responder_message: - 'Hello, I am out of office and unable to respond to messages.', - comments: 'never', - desktop_notification_sound: 'Hello', - push_status: 'online', - }, - last_password_update: 1604323112537, - last_picture_update: 1604686302260, - locale: 'en', - timezone: { - automaticTimezone: 'Indian/Mauritius', - manualTimezone: '', - useAutomaticTimezone: true, - }, - }, - }, - ], - tableName: 'User', - updateRaws: [], - recordOperator: operateUserRecord, - }); - }); - - it('=> HandlePreferences: should write to PREFERENCE entity', async () => { - expect.assertions(2); - - const preferences = [ - { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'group_channel_show', - name: 'qj91hepgjfn6xr4acm5xzd8zoc', - value: 'true', - }, - { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'notifications', - name: 'email_interval', - value: '30', - }, - { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'theme', - name: '', - value: - '{"awayIndicator":"#c1b966","buttonBg":"#4cbba4","buttonColor":"#ffffff","centerChannelBg":"#2f3e4e","centerChannelColor":"#dddddd","codeTheme":"solarized-dark","dndIndicator":"#e81023","errorTextColor":"#ff6461","image":"/static/files/0b8d56c39baf992e5e4c58d74fde0fd6.png","linkColor":"#a4ffeb","mentionBg":"#b74a4a","mentionColor":"#ffffff","mentionHighlightBg":"#984063","mentionHighlightLink":"#a4ffeb","newMessageSeparator":"#5de5da","onlineIndicator":"#65dcc8","sidebarBg":"#1b2c3e","sidebarHeaderBg":"#1b2c3e","sidebarHeaderTextColor":"#ffffff","sidebarText":"#ffffff","sidebarTextActiveBorder":"#66b9a7","sidebarTextActiveColor":"#ffffff","sidebarTextHoverBg":"#4a5664","sidebarUnreadText":"#ffffff","type":"Mattermost Dark"}', - }, - { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'tutorial_step', - name: '9ciscaqbrpd6d8s68k76xb9bte', - value: '2', - }, - ]; - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handlePreferences(preferences); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'group_channel_show', - name: 'qj91hepgjfn6xr4acm5xzd8zoc', - value: 'true', - }, - }, - { - raw: { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'notifications', - name: 'email_interval', - value: '30', - }, - }, - { - raw: { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'theme', - name: '', - value: - '{"awayIndicator":"#c1b966","buttonBg":"#4cbba4","buttonColor":"#ffffff","centerChannelBg":"#2f3e4e","centerChannelColor":"#dddddd","codeTheme":"solarized-dark","dndIndicator":"#e81023","errorTextColor":"#ff6461","image":"/static/files/0b8d56c39baf992e5e4c58d74fde0fd6.png","linkColor":"#a4ffeb","mentionBg":"#b74a4a","mentionColor":"#ffffff","mentionHighlightBg":"#984063","mentionHighlightLink":"#a4ffeb","newMessageSeparator":"#5de5da","onlineIndicator":"#65dcc8","sidebarBg":"#1b2c3e","sidebarHeaderBg":"#1b2c3e","sidebarHeaderTextColor":"#ffffff","sidebarText":"#ffffff","sidebarTextActiveBorder":"#66b9a7","sidebarTextActiveColor":"#ffffff","sidebarTextHoverBg":"#4a5664","sidebarUnreadText":"#ffffff","type":"Mattermost Dark"}', - }, - }, - { - raw: { - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - category: 'tutorial_step', - name: '9ciscaqbrpd6d8s68k76xb9bte', - value: '2', - }, - }, - ], - tableName: 'Preference', - updateRaws: [], - recordOperator: operatePreferenceRecord, - }); - }); - - it('=> HandleTeamMemberships: should write to TEAM_MEMBERSHIP entity', async () => { - expect.assertions(2); - - const teamMembership = [ - { - team_id: 'a', - user_id: 'ab', - roles: '3ngdqe1e7tfcbmam4qgnxp91bw', - delete_at: 0, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - ]; - - const spyOnExecuteInDatabase = jest.spyOn( - DataOperator as any, - 'executeInDatabase', - ); - - await createConnection(true); - - await DataOperator.handleTeamMemberships(teamMembership); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - team_id: 'a', - user_id: 'ab', - roles: '3ngdqe1e7tfcbmam4qgnxp91bw', - delete_at: 0, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - }, - ], - tableName: 'TeamMembership', - updateRaws: [], - recordOperator: operateTeamMembershipRecord, - }); - }); - - it('=> HandleCustomEmojis: should write to CUSTOM_EMOJI entity', async () => { - expect.assertions(2); - const emojis = [ - { - id: 'i', - create_at: 1580913641769, - update_at: 1580913641769, - delete_at: 0, - creator_id: '4cprpki7ri81mbx8efixcsb8jo', - name: 'boomI', - }, - ]; - - const spyOnExecuteInDatabase = jest.spyOn( - DataOperator as any, - 'executeInDatabase', - ); - - await createConnection(true); - - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.CUSTOM_EMOJI, - values: emojis, - }); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - id: 'i', - create_at: 1580913641769, - update_at: 1580913641769, - delete_at: 0, - creator_id: '4cprpki7ri81mbx8efixcsb8jo', - name: 'boomI', - }, - }, - ], - tableName: 'CustomEmoji', - updateRaws: [], - recordOperator: operateCustomEmojiRecord, - }); - }); - - it('=> HandleGroupMembership: should write to GROUP_MEMBERSHIP entity', async () => { - expect.assertions(2); - const groupMemberships = [ - { - user_id: 'u4cprpki7ri81mbx8efixcsb8jo', - group_id: 'g4cprpki7ri81mbx8efixcsb8jo', - }, - ]; - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleGroupMembership(groupMemberships); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - user_id: 'u4cprpki7ri81mbx8efixcsb8jo', - group_id: 'g4cprpki7ri81mbx8efixcsb8jo', - }, - }, - ], - tableName: 'GroupMembership', - updateRaws: [], - recordOperator: operateGroupMembershipRecord, - }); - }); - - it('=> HandleChannelMembership: should write to CHANNEL_MEMBERSHIP entity', async () => { - expect.assertions(2); - const channelMemberships = [ - { - channel_id: '17bfnb1uwb8epewp4q3x3rx9go', - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - roles: 'wqyby5r5pinxxdqhoaomtacdhc', - last_viewed_at: 1613667352029, - msg_count: 3864, - mention_count: 0, - notify_props: { - desktop: 'default', - email: 'default', - ignore_channel_mentions: 'default', - mark_unread: 'mention', - push: 'default', - }, - last_update_at: 1613667352029, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - { - channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew', - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - roles: 'channel_user', - last_viewed_at: 1615300540549, - msg_count: 16, - mention_count: 0, - notify_props: { - desktop: 'default', - email: 'default', - ignore_channel_mentions: 'default', - mark_unread: 'all', - push: 'default', - }, - last_update_at: 1615300540549, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - ]; - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleChannelMembership(channelMemberships); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - channel_id: '17bfnb1uwb8epewp4q3x3rx9go', - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - roles: 'wqyby5r5pinxxdqhoaomtacdhc', - last_viewed_at: 1613667352029, - msg_count: 3864, - mention_count: 0, - notify_props: { - desktop: 'default', - email: 'default', - ignore_channel_mentions: 'default', - mark_unread: 'mention', - push: 'default', - }, - last_update_at: 1613667352029, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - }, - { - raw: { - channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew', - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - roles: 'channel_user', - last_viewed_at: 1615300540549, - msg_count: 16, - mention_count: 0, - notify_props: { - desktop: 'default', - email: 'default', - ignore_channel_mentions: 'default', - mark_unread: 'all', - push: 'default', - }, - last_update_at: 1615300540549, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - }, - ], - tableName: 'ChannelMembership', - updateRaws: [], - recordOperator: operateChannelMembershipRecord, - }); - }); - - it('=> HandleGroup: should write to GROUP entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn( - DataOperator as any, - 'executeInDatabase', - ); - - await createConnection(true); - - await DataOperator.handleGroup([ - { - id: 'id_groupdfjdlfkjdkfdsf', - name: 'mobile_team', - display_name: 'mobile team', - description: '', - source: '', - remote_id: '', - create_at: 0, - update_at: 0, - delete_at: 0, - has_syncables: true, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - id: 'id_groupdfjdlfkjdkfdsf', - name: 'mobile_team', - display_name: 'mobile team', - description: '', - source: '', - remote_id: '', - create_at: 0, - update_at: 0, - delete_at: 0, - has_syncables: true, - }, - }, - ], - tableName: 'Group', - updateRaws: [], - recordOperator: operateGroupRecord, - }); - }); - - it('=> HandleGroupsInTeam: should write to GROUPS_IN_TEAM entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleGroupsInTeam([ - { - team_id: 'team_899', - team_display_name: '', - team_type: '', - group_id: 'group_id89', - auto_add: true, - create_at: 0, - delete_at: 0, - update_at: 0, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - team_id: 'team_899', - team_display_name: '', - team_type: '', - group_id: 'group_id89', - auto_add: true, - create_at: 0, - delete_at: 0, - update_at: 0, - }, - }, - ], - tableName: 'GroupsInTeam', - updateRaws: [], - recordOperator: operateGroupsInTeamRecord, - }); - }); - - it('=> HandleGroupsInChannel: should write to GROUPS_IN_CHANNEL entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleGroupsInChannel([ - { - auto_add: true, - channel_display_name: '', - channel_id: 'channelid', - channel_type: '', - create_at: 0, - delete_at: 0, - group_id: 'groupId', - team_display_name: '', - team_id: '', - team_type: '', - update_at: 0, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - auto_add: true, - channel_display_name: '', - channel_id: 'channelid', - channel_type: '', - create_at: 0, - delete_at: 0, - group_id: 'groupId', - team_display_name: '', - team_id: '', - team_type: '', - update_at: 0, - }, - }, - ], - tableName: 'GroupsInChannel', - updateRaws: [], - recordOperator: operateGroupsInChannelRecord, - }); - }); - - it('=> HandleTeam: should write to TEAM entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleTeam([ - { - id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby', - create_at: 1445538153952, - update_at: 1588876392150, - delete_at: 0, - display_name: 'Contributors', - name: 'core', - description: '', - email: '', - type: 'O', - company_name: '', - allowed_domains: '', - invite_id: 'codoy5s743rq5mk18i7u5dfdfksz7e', - allow_open_invite: true, - last_team_icon_update: 1525181587639, - scheme_id: 'hbwgrncq1pfcdkpotzidfdmarn95o', - group_constrained: null, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby', - create_at: 1445538153952, - update_at: 1588876392150, - delete_at: 0, - display_name: 'Contributors', - name: 'core', - description: '', - email: '', - type: 'O', - company_name: '', - allowed_domains: '', - invite_id: 'codoy5s743rq5mk18i7u5dfdfksz7e', - allow_open_invite: true, - last_team_icon_update: 1525181587639, - scheme_id: 'hbwgrncq1pfcdkpotzidfdmarn95o', - group_constrained: null, - }, - }, - ], - tableName: 'Team', - updateRaws: [], - recordOperator: operateTeamRecord, - }); - }); - - it('=> HandleTeamChannelHistory: should write to TEAM_CHANNEL_HISTORY entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleTeamChannelHistory([ - { - team_id: 'a', - channel_ids: ['ca', 'cb'], - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [{raw: {team_id: 'a', channel_ids: ['ca', 'cb']}}], - tableName: 'TeamChannelHistory', - updateRaws: [], - recordOperator: operateTeamChannelHistoryRecord, - }); - }); - - it('=> HandleTeamSearchHistory: should write to TEAM_SEARCH_HISTORY entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleTeamSearchHistory([ - { - team_id: 'a', - term: 'termA', - display_term: 'termA', - created_at: 1445538153952, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - team_id: 'a', - term: 'termA', - display_term: 'termA', - created_at: 1445538153952, - }, - }, - ], - tableName: 'TeamSearchHistory', - updateRaws: [], - recordOperator: operateTeamSearchHistoryRecord, - }); - }); - - it('=> HandleSlashCommand: should write to SLASH_COMMAND entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleSlashCommand([ - { - id: 'command_1', - auto_complete: true, - auto_complete_desc: 'mock_command', - auto_complete_hint: 'hint', - create_at: 1445538153952, - creator_id: 'creator_id', - delete_at: 1445538153952, - description: 'description', - display_name: 'display_name', - icon_url: 'display_name', - method: 'get', - team_id: 'teamA', - token: 'token', - trigger: 'trigger', - update_at: 1445538153953, - url: 'url', - username: 'userA', - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - id: 'command_1', - auto_complete: true, - auto_complete_desc: 'mock_command', - auto_complete_hint: 'hint', - create_at: 1445538153952, - creator_id: 'creator_id', - delete_at: 1445538153952, - description: 'description', - display_name: 'display_name', - icon_url: 'display_name', - method: 'get', - team_id: 'teamA', - token: 'token', - trigger: 'trigger', - update_at: 1445538153953, - url: 'url', - username: 'userA', - }, - }, - ], - tableName: 'SlashCommand', - updateRaws: [], - recordOperator: operateSlashCommandRecord, - }); - }); - - it('=> HandleMyTeam: should write to MY_TEAM entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleMyTeam([ - { - team_id: 'teamA', - roles: 'roleA, roleB, roleC', - is_unread: true, - mentions_count: 3, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - team_id: 'teamA', - roles: 'roleA, roleB, roleC', - is_unread: true, - mentions_count: 3, - }, - }, - ], - tableName: 'MyTeam', - updateRaws: [], - recordOperator: operateMyTeamRecord, - }); - }); - - it('=> HandleChannel: should write to CHANNEL entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleChannel([ - { - id: 'kjlw9j1ttnxwig7tnqgebg7dtipno', - create_at: 1600185541285, - update_at: 1604401077256, - delete_at: 0, - team_id: '', - type: 'D', - display_name: '', - name: 'gh781zkzkhh357b4bejephjz5u8daw__9ciscaqbrpd6d8s68k76xb9bte', - header: '(https://mattermost', - purpose: '', - last_post_at: 1617311494451, - total_msg_count: 585, - extra_update_at: 0, - creator_id: '', - scheme_id: null, - props: null, - group_constrained: null, - shared: null, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - id: 'kjlw9j1ttnxwig7tnqgebg7dtipno', - create_at: 1600185541285, - update_at: 1604401077256, - delete_at: 0, - team_id: '', - type: 'D', - display_name: '', - name: - 'gh781zkzkhh357b4bejephjz5u8daw__9ciscaqbrpd6d8s68k76xb9bte', - header: '(https://mattermost', - purpose: '', - last_post_at: 1617311494451, - total_msg_count: 585, - extra_update_at: 0, - creator_id: '', - scheme_id: null, - props: null, - group_constrained: null, - shared: null, - }, - }, - ], - tableName: 'Channel', - updateRaws: [], - recordOperator: operateChannelRecord, - }); - }); - - it('=> HandleMyChannelSettings: should write to MY_CHANNEL_SETTINGS entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleMyChannelSettings([ - { - channel_id: 'c', - notify_props: { - desktop: 'all', - desktop_sound: true, - email: true, - first_name: true, - mention_keys: '', - push: 'mention', - channel: true, - }, - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - channel_id: 'c', - notify_props: { - desktop: 'all', - desktop_sound: true, - email: true, - first_name: true, - mention_keys: '', - push: 'mention', - channel: true, - }, - }, - }, - ], - tableName: 'MyChannelSettings', - updateRaws: [], - recordOperator: operateMyChannelSettingsRecord, - }); - }); - - it('=> HandleChannelInfo: should write to CHANNEL_INFO entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleChannelInfo([ - { - channel_id: 'c', - guest_count: 10, - header: 'channel info header', - member_count: 10, - pinned_post_count: 3, - purpose: 'sample channel ', - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - channel_id: 'c', - guest_count: 10, - header: 'channel info header', - member_count: 10, - pinned_post_count: 3, - purpose: 'sample channel ', - }, - }, - ], - tableName: 'ChannelInfo', - updateRaws: [], - recordOperator: operateChannelInfoRecord, - }); - }); - - it('=> HandleMyChannel: should write to MY_CHANNEL entity', async () => { - expect.assertions(2); - - const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await createConnection(true); - - await DataOperator.handleMyChannel([ - { - channel_id: 'c', - last_post_at: 1617311494451, - last_viewed_at: 1617311494451, - mentions_count: 3, - message_count: 10, - roles: 'guest', - }, - ]); - - expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); - expect(spyOnExecuteInDatabase).toHaveBeenCalledWith({ - createRaws: [ - { - raw: { - channel_id: 'c', - last_post_at: 1617311494451, - last_viewed_at: 1617311494451, - mentions_count: 3, - message_count: 10, - roles: 'guest', - }, - }, - ], - tableName: 'MyChannel', - updateRaws: [], - recordOperator: operateMyChannelRecord, - }); - }); -}); diff --git a/app/database/admin/data_operator/index.ts b/app/database/admin/data_operator/index.ts deleted file mode 100644 index b4a742c817..0000000000 --- a/app/database/admin/data_operator/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import DataOperator from './handlers'; - -export default new DataOperator(); diff --git a/app/database/admin/data_operator/operators/index.ts b/app/database/admin/data_operator/operators/index.ts deleted file mode 100644 index 75ef23c16d..0000000000 --- a/app/database/admin/data_operator/operators/index.ts +++ /dev/null @@ -1,1064 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Q} from '@nozbe/watermelondb'; -import Model from '@nozbe/watermelondb/Model'; - -import {MM_TABLES} from '@constants/database'; -import App from '@typings/database/app'; -import Channel from '@typings/database/channel'; -import ChannelInfo from '@typings/database/channel_info'; -import ChannelMembership from '@typings/database/channel_membership'; -import CustomEmoji from '@typings/database/custom_emoji'; -import { - DataFactoryArgs, - RawApp, - RawChannel, - RawChannelInfo, - RawChannelMembership, - RawCustomEmoji, - RawDraft, - RawFile, - RawGlobal, - RawGroup, - RawGroupMembership, - RawGroupsInChannel, - RawGroupsInTeam, - RawMyChannel, - RawMyChannelSettings, - RawMyTeam, - RawPost, - RawPostMetadata, - RawPostsInChannel, - RawPostsInThread, - RawPreference, - RawReaction, - RawRole, - RawServers, - RawSlashCommand, - RawSystem, - RawTeam, - RawTeamChannelHistory, - RawTeamMembership, - RawTeamSearchHistory, - RawTermsOfService, - RawUser, -} from '@typings/database/database'; -import Draft from '@typings/database/draft'; -import {OperationType} from '@typings/database/enums'; -import File from '@typings/database/file'; -import Global from '@typings/database/global'; -import Group from '@typings/database/group'; -import GroupMembership from '@typings/database/group_membership'; -import GroupsInChannel from '@typings/database/groups_in_channel'; -import GroupsInTeam from '@typings/database/groups_in_team'; -import MyChannel from '@typings/database/my_channel'; -import MyChannelSettings from '@typings/database/my_channel_settings'; -import MyTeam from '@typings/database/my_team'; -import Post from '@typings/database/post'; -import PostMetadata from '@typings/database/post_metadata'; -import PostsInChannel from '@typings/database/posts_in_channel'; -import PostsInThread from '@typings/database/posts_in_thread'; -import Preference from '@typings/database/preference'; -import Reaction from '@typings/database/reaction'; -import Role from '@typings/database/role'; -import Servers from '@typings/database/servers'; -import SlashCommand from '@typings/database/slash_command'; -import System from '@typings/database/system'; -import Team from '@typings/database/team'; -import TeamChannelHistory from '@typings/database/team_channel_history'; -import TeamMembership from '@typings/database/team_membership'; -import TeamSearchHistory from '@typings/database/team_search_history'; -import TermsOfService from '@typings/database/terms_of_service'; -import User from '@typings/database/user'; - -const {APP, GLOBAL, SERVERS} = MM_TABLES.DEFAULT; -const { - CHANNEL, - CHANNEL_INFO, - CHANNEL_MEMBERSHIP, - CUSTOM_EMOJI, - DRAFT, - FILE, - GROUP, - GROUPS_IN_CHANNEL, - GROUPS_IN_TEAM, - GROUP_MEMBERSHIP, - MY_CHANNEL, - MY_CHANNEL_SETTINGS, - MY_TEAM, - POST, - POSTS_IN_CHANNEL, - POSTS_IN_THREAD, - POST_METADATA, - PREFERENCE, - REACTION, - ROLE, - SLASH_COMMAND, - SYSTEM, - TEAM, - TEAM_CHANNEL_HISTORY, - TEAM_MEMBERSHIP, - TEAM_SEARCH_HISTORY, - TERMS_OF_SERVICE, - USER, -} = MM_TABLES.SERVER; - -/** - * operateAppRecord: Prepares record of entity 'App' from the DEFAULT database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateAppRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawApp; - const record = value.record as App; - const isCreateAction = action === OperationType.CREATE; - - const generator = (app: App) => { - app._raw.id = isCreateAction ? app.id : record.id; - app.buildNumber = raw?.build_number; - app.createdAt = raw?.created_at; - app.versionNumber = raw?.version_number; - }; - - return operateBaseRecord({ - action, - database, - generator, - tableName: APP, - value, - }); -}; - -/** - * operateGlobalRecord: Prepares record of entity 'Global' from the DEFAULT database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateGlobalRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawGlobal; - const record = value.record as Global; - const isCreateAction = action === OperationType.CREATE; - - const generator = (global: Global) => { - global._raw.id = isCreateAction ? global.id : record.id; - global.name = raw?.name; - global.value = raw?.value; - }; - - return operateBaseRecord({ - action, - database, - generator, - tableName: GLOBAL, - value, - }); -}; - -/** - * operateServersRecord: Prepares record of entity 'Servers' from the DEFAULT database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateServersRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawServers; - const record = value.record as Servers; - const isCreateAction = action === OperationType.CREATE; - - const generator = (servers: Servers) => { - servers._raw.id = isCreateAction ? servers.id : record.id; - servers.dbPath = raw?.db_path; - servers.displayName = raw?.display_name; - servers.mentionCount = raw?.mention_count; - servers.unreadCount = raw?.unread_count; - servers.url = raw?.url; - }; - - return operateBaseRecord({ - action, - database, - tableName: SERVERS, - value, - generator, - }); -}; - -/** - * operateCustomEmojiRecord: Prepares record of entity 'CustomEmoji' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateCustomEmojiRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawCustomEmoji; - const record = value.record as CustomEmoji; - const isCreateAction = action === OperationType.CREATE; - - // id of emoji comes from server response - const generator = (emoji: CustomEmoji) => { - emoji._raw.id = isCreateAction ? (raw?.id ?? emoji.id) : record.id; - emoji.name = raw.name; - }; - - return operateBaseRecord({ - action, - database, - tableName: CUSTOM_EMOJI, - value, - generator, - }); -}; - -/** - * operateRoleRecord: Prepares record of entity 'Role' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateRoleRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawRole; - const record = value.record as Role; - const isCreateAction = action === OperationType.CREATE; - - // id of role comes from server response - const generator = (role: Role) => { - role._raw.id = isCreateAction ? (raw?.id ?? role.id) : record.id; - role.name = raw?.name; - role.permissions = raw?.permissions; - }; - - return operateBaseRecord({ - action, - database, - tableName: ROLE, - value, - generator, - }); -}; - -/** - * operateSystemRecord: Prepares record of entity 'System' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateSystemRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawSystem; - const record = value.record as System; - const isCreateAction = action === OperationType.CREATE; - - // id of system comes from server response - const generator = (system: System) => { - system._raw.id = isCreateAction ? (raw?.id ?? system.id) : record?.id; - system.name = raw?.name; - system.value = raw?.value; - }; - - return operateBaseRecord({ - action, - database, - tableName: SYSTEM, - value, - generator, - }); -}; - -/** - * operateTermsOfServiceRecord: Prepares record of entity 'TermsOfService' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateTermsOfServiceRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawTermsOfService; - const record = value.record as TermsOfService; - const isCreateAction = action === OperationType.CREATE; - - // id of TOS comes from server response - const generator = (tos: TermsOfService) => { - tos._raw.id = isCreateAction ? (raw?.id ?? tos.id) : record?.id; - tos.acceptedAt = raw?.accepted_at; - }; - - return operateBaseRecord({ - action, - database, - tableName: TERMS_OF_SERVICE, - value, - generator, - }); -}; - -/** - * operatePostRecord: Prepares record of entity 'Post' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operatePostRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawPost; - const record = value.record as Post; - const isCreateAction = action === OperationType.CREATE; - - // id of post comes from server response - const generator = (post: Post) => { - post._raw.id = isCreateAction ? (raw?.id ?? post.id) : record?.id; - post.channelId = raw?.channel_id; - post.createAt = raw?.create_at; - post.deleteAt = raw?.delete_at || raw?.delete_at === 0 ? raw?.delete_at : 0; - post.editAt = raw?.edit_at; - post.updateAt = raw?.update_at; - post.isPinned = raw!.is_pinned!; - post.message = Q.sanitizeLikeString(raw?.message); - post.userId = raw?.user_id; - post.originalId = raw?.original_id ?? ''; - post.pendingPostId = raw?.pending_post_id ?? ''; - post.previousPostId = raw?.prev_post_id ?? ''; - post.rootId = raw?.root_id ?? ''; - post.type = raw?.type ?? ''; - post.props = raw?.props ?? {}; - }; - - return operateBaseRecord({ - action, - database, - tableName: POST, - value, - generator, - }); -}; - -/** - * operatePostInThreadRecord: Prepares record of entity 'POSTS_IN_THREAD' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operatePostInThreadRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawPostsInThread; - const record = value.record as PostsInThread; - const isCreateAction = action === OperationType.CREATE; - - const generator = (postsInThread: PostsInThread) => { - postsInThread.postId = isCreateAction ? raw.post_id : record.id; - postsInThread.earliest = raw.earliest; - postsInThread.latest = raw.latest!; - }; - - return operateBaseRecord({ - action, - database, - tableName: POSTS_IN_THREAD, - value, - generator, - }); -}; - -/** - * operateReactionRecord: Prepares record of entity 'REACTION' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateReactionRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawReaction; - const record = value.record as Reaction; - const isCreateAction = action === OperationType.CREATE; - - // id of reaction comes from server response - const generator = (reaction: Reaction) => { - reaction._raw.id = isCreateAction ? reaction.id : record?.id; - reaction.userId = raw.user_id; - reaction.postId = raw.post_id; - reaction.emojiName = raw.emoji_name; - reaction.createAt = raw.create_at; - }; - - return operateBaseRecord({ - action, - database, - tableName: REACTION, - value, - generator, - }); -}; - -/** - * operateFileRecord: Prepares record of entity 'FILE' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateFileRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawFile; - const record = value.record as File; - const isCreateAction = action === OperationType.CREATE; - - // id of file comes from server response - const generator = (file: File) => { - file._raw.id = isCreateAction ? (raw?.id ?? file.id) : record?.id; - file.postId = raw.post_id; - file.name = raw.name; - file.extension = raw.extension; - file.size = raw.size; - file.mimeType = raw?.mime_type ?? ''; - file.width = raw?.width ?? 0; - file.height = raw?.height ?? 0; - file.imageThumbnail = raw?.mini_preview ?? ''; - file.localPath = raw?.localPath ?? ''; - }; - - return operateBaseRecord({ - action, - database, - tableName: FILE, - value, - generator, - }); -}; - -/** - * operatePostMetadataRecord: Prepares record of entity 'POST_METADATA' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operatePostMetadataRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawPostMetadata; - const record = value.record as PostMetadata; - const isCreateAction = action === OperationType.CREATE; - - const generator = (postMeta: PostMetadata) => { - postMeta._raw.id = isCreateAction ? postMeta.id : record.id; - postMeta.data = raw.data; - postMeta.postId = raw.postId; - postMeta.type = raw.type; - }; - - return operateBaseRecord({ - action, - database, - tableName: POST_METADATA, - value, - generator, - }); -}; - -/** - * operateDraftRecord: Prepares record of entity 'DRAFT' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateDraftRecord = async ({action, database, value}: DataFactoryArgs) => { - const emptyFileInfo: FileInfo[] = []; - const raw = value.raw as RawDraft; - - // Draft is client side only; plus you would only be creating/deleting one - const generator = (draft: Draft) => { - draft._raw.id = draft.id; - draft.rootId = raw?.root_id ?? ''; - draft.message = raw?.message ?? ''; - draft.channelId = raw?.channel_id ?? ''; - draft.files = raw?.files ?? emptyFileInfo; - }; - - return operateBaseRecord({ - action, - database, - tableName: DRAFT, - value, - generator, - }); -}; - -/** - * operatePostsInChannelRecord: Prepares record of entity 'POSTS_IN_CHANNEL' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operatePostsInChannelRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawPostsInChannel; - const record = value.record as PostsInChannel; - const isCreateAction = action === OperationType.CREATE; - - const generator = (postsInChannel: PostsInChannel) => { - postsInChannel._raw.id = isCreateAction ? postsInChannel.id : record.id; - postsInChannel.channelId = raw.channel_id; - postsInChannel.earliest = raw.earliest; - postsInChannel.latest = raw.latest; - }; - - return operateBaseRecord({ - action, - database, - tableName: POSTS_IN_CHANNEL, - value, - generator, - }); -}; - -/** - * operateUserRecord: Prepares record of entity 'USER' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateUserRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawUser; - const record = value.record as User; - const isCreateAction = action === OperationType.CREATE; - - // id of user comes from server response - const generator = (user: User) => { - user._raw.id = isCreateAction ? (raw?.id ?? user.id) : record?.id; - user.authService = raw.auth_service; - user.deleteAt = raw.delete_at; - user.updateAt = raw.update_at; - user.email = raw.email; - user.firstName = raw.first_name; - user.isGuest = raw.roles.includes('system_guest'); - user.lastName = raw.last_name; - user.lastPictureUpdate = raw.last_picture_update; - user.locale = raw.locale; - user.nickname = raw.nickname; - user.position = raw?.position ?? ''; - user.roles = raw.roles; - user.username = raw.username; - user.notifyProps = raw.notify_props; - user.props = raw.props; - user.timezone = raw.timezone; - user.isBot = raw.is_bot; - }; - - return operateBaseRecord({ - action, - database, - tableName: USER, - value, - generator, - }); -}; - -/** - * operatePreferenceRecord: Prepares record of entity 'PREFERENCE' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operatePreferenceRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawPreference; - const record = value.record as Preference; - const isCreateAction = action === OperationType.CREATE; - - // id of preference comes from server response - const generator = (preference: Preference) => { - preference._raw.id = isCreateAction ? preference.id : record?.id; - preference.category = raw.category; - preference.name = raw.name; - preference.userId = raw.user_id; - preference.value = raw.value; - }; - - return operateBaseRecord({ - action, - database, - tableName: PREFERENCE, - value, - generator, - }); -}; - -/** - * operatePreferenceRecord: Prepares record of entity 'TEAM_MEMBERSHIP' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateTeamMembershipRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawTeamMembership; - const record = value.record as TeamMembership; - const isCreateAction = action === OperationType.CREATE; - - // id of preference comes from server response - const generator = (teamMembership: TeamMembership) => { - teamMembership._raw.id = isCreateAction ? teamMembership.id : record?.id; - teamMembership.teamId = raw.team_id; - teamMembership.userId = raw.user_id; - }; - - return operateBaseRecord({ - action, - database, - tableName: TEAM_MEMBERSHIP, - value, - generator, - }); -}; - -/** - * operateGroupMembershipRecord: Prepares record of entity 'GROUP_MEMBERSHIP' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateGroupMembershipRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawGroupMembership; - const record = value.record as GroupMembership; - const isCreateAction = action === OperationType.CREATE; - - // id of preference comes from server response - const generator = (groupMember: GroupMembership) => { - groupMember._raw.id = isCreateAction ? groupMember.id : record?.id; - groupMember.groupId = raw.group_id; - groupMember.userId = raw.user_id; - }; - - return operateBaseRecord({ - action, - database, - tableName: GROUP_MEMBERSHIP, - value, - generator, - }); -}; - -/** - * operateChannelMembershipRecord: Prepares record of entity 'CHANNEL_MEMBERSHIP' from the SERVER database for update or create actions. - * @param {DataFactoryArgs} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateChannelMembershipRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawChannelMembership; - const record = value.record as ChannelMembership; - const isCreateAction = action === OperationType.CREATE; - - // id of preference comes from server response - const generator = (channelMember: ChannelMembership) => { - channelMember._raw.id = isCreateAction ? channelMember.id : record?.id; - channelMember.channelId = raw.channel_id; - channelMember.userId = raw.user_id; - }; - - return operateBaseRecord({ - action, - database, - tableName: CHANNEL_MEMBERSHIP, - value, - generator, - }); -}; - -/** - * operateGroupRecord: Prepares record of entity 'GROUP' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateGroupRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawGroup; - const record = value.record as Group; - const isCreateAction = action === OperationType.CREATE; - - // id of preference comes from server response - const generator = (group: Group) => { - group._raw.id = isCreateAction ? (raw?.id ?? group.id) : record?.id; - group.name = raw.name; - group.displayName = raw.display_name; - }; - - return operateBaseRecord({ - action, - database, - tableName: GROUP, - value, - generator, - }); -}; - -/** - * operateGroupsInTeamRecord: Prepares record of entity 'GROUPS_IN_TEAM' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateGroupsInTeamRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawGroupsInTeam; - const record = value.record as GroupsInTeam; - const isCreateAction = action === OperationType.CREATE; - - // FIXME : should include memberCount and timezoneCount or will it be by update action? - - const generator = (groupsInTeam: GroupsInTeam) => { - groupsInTeam._raw.id = isCreateAction ? groupsInTeam.id : record?.id; - groupsInTeam.teamId = raw.team_id; - groupsInTeam.groupId = raw.group_id; - }; - - return operateBaseRecord({ - action, - database, - tableName: GROUPS_IN_TEAM, - value, - generator, - }); -}; - -/** - * operateGroupsInChannelRecord: Prepares record of entity 'GROUPS_IN_CHANNEL' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateGroupsInChannelRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawGroupsInChannel; - const record = value.record as GroupsInChannel; - const isCreateAction = action === OperationType.CREATE; - - // FIXME : should include memberCount and timezoneCount or will it be by update action? - const generator = (groupsInChannel: GroupsInChannel) => { - groupsInChannel._raw.id = isCreateAction ? groupsInChannel.id : record?.id; - groupsInChannel.channelId = raw.channel_id; - groupsInChannel.groupId = raw.group_id; - }; - - return operateBaseRecord({ - action, - database, - tableName: GROUPS_IN_CHANNEL, - value, - generator, - }); -}; - -/** - * operateTeamRecord: Prepares record of entity 'TEAM' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateTeamRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawTeam; - const record = value.record as Team; - const isCreateAction = action === OperationType.CREATE; - - // id of team comes from server response - const generator = (team: Team) => { - team._raw.id = isCreateAction ? (raw?.id ?? team.id) : record?.id; - team.isAllowOpenInvite = raw.allow_open_invite; - team.description = raw.description; - team.displayName = raw.display_name; - team.name = raw.name; - team.updateAt = raw.update_at; - team.type = raw.type; - team.allowedDomains = raw.allowed_domains; - team.isGroupConstrained = Boolean(raw.group_constrained); - team.lastTeamIconUpdatedAt = raw.last_team_icon_update; - }; - - return operateBaseRecord({ - action, - database, - tableName: TEAM, - value, - generator, - }); -}; - -/** - * operateTeamChannelHistoryRecord: Prepares record of entity 'TEAM_CHANNEL_HISTORY' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateTeamChannelHistoryRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawTeamChannelHistory; - const record = value.record as TeamChannelHistory; - const isCreateAction = action === OperationType.CREATE; - - const generator = (teamChannelHistory: TeamChannelHistory) => { - teamChannelHistory._raw.id = isCreateAction ? (teamChannelHistory.id) : record?.id; - teamChannelHistory.teamId = raw.team_id; - teamChannelHistory.channelIds = raw.channel_ids; - }; - - return operateBaseRecord({ - action, - database, - tableName: TEAM_CHANNEL_HISTORY, - value, - generator, - }); -}; - -/** - * operateTeamSearchHistoryRecord: Prepares record of entity 'TEAM_SEARCH_HISTORY' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateTeamSearchHistoryRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawTeamSearchHistory; - const record = value.record as TeamSearchHistory; - const isCreateAction = action === OperationType.CREATE; - - const generator = (teamSearchHistory: TeamSearchHistory) => { - teamSearchHistory._raw.id = isCreateAction ? (teamSearchHistory.id) : record?.id; - teamSearchHistory.createdAt = raw.created_at; - teamSearchHistory.displayTerm = raw.display_term; - teamSearchHistory.term = raw.term; - teamSearchHistory.teamId = raw.team_id; - }; - - return operateBaseRecord({ - action, - database, - tableName: TEAM_SEARCH_HISTORY, - value, - generator, - }); -}; - -/** - * operateSlashCommandRecord: Prepares record of entity 'SLASH_COMMAND' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateSlashCommandRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawSlashCommand; - const record = value.record as SlashCommand; - const isCreateAction = action === OperationType.CREATE; - - // id of team comes from server response - const generator = (slashCommand: SlashCommand) => { - slashCommand._raw.id = isCreateAction ? (raw?.id ?? slashCommand.id) : record?.id; - slashCommand.isAutoComplete = raw.auto_complete; - slashCommand.description = raw.description; - slashCommand.displayName = raw.display_name; - slashCommand.hint = raw.auto_complete_hint; - slashCommand.method = raw.method; - slashCommand.teamId = raw.team_id; - slashCommand.token = raw.token; - slashCommand.trigger = raw.trigger; - slashCommand.updateAt = raw.update_at; - }; - - return operateBaseRecord({ - action, - database, - tableName: SLASH_COMMAND, - value, - generator, - }); -}; - -/** - * operateMyTeamRecord: Prepares record of entity 'MY_TEAM' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateMyTeamRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawMyTeam; - const record = value.record as MyTeam; - const isCreateAction = action === OperationType.CREATE; - - const generator = (myTeam: MyTeam) => { - myTeam._raw.id = isCreateAction ? myTeam.id : record?.id; - myTeam.teamId = raw.team_id; - myTeam.roles = raw.roles; - myTeam.isUnread = raw.is_unread; - myTeam.mentionsCount = raw.mentions_count; - }; - - return operateBaseRecord({ - action, - database, - tableName: MY_TEAM, - value, - generator, - }); -}; - -/** - * operateChannelRecord: Prepares record of entity 'CHANNEL' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateChannelRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawChannel; - const record = value.record as Channel; - const isCreateAction = action === OperationType.CREATE; - - // id of team comes from server response - const generator = (channel: Channel) => { - channel._raw.id = isCreateAction ? (raw?.id ?? channel.id) : record?.id; - channel.createAt = raw.create_at; - channel.creatorId = raw.creator_id; - channel.deleteAt = raw.delete_at; - channel.displayName = raw.display_name; - channel.isGroupConstrained = Boolean(raw.group_constrained); - channel.name = raw.name; - channel.teamId = raw.team_id; - channel.type = raw.type; - }; - - return operateBaseRecord({ - action, - database, - tableName: CHANNEL, - value, - generator, - }); -}; - -/** - * operateMyChannelSettingsRecord: Prepares record of entity 'MY_CHANNEL_SETTINGS' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateMyChannelSettingsRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawMyChannelSettings; - const record = value.record as MyChannelSettings; - const isCreateAction = action === OperationType.CREATE; - - const generator = (myChannelSetting: MyChannelSettings) => { - myChannelSetting._raw.id = isCreateAction ? myChannelSetting.id : record?.id; - myChannelSetting.channelId = raw.channel_id; - myChannelSetting.notifyProps = raw.notify_props; - }; - - return operateBaseRecord({ - action, - database, - tableName: MY_CHANNEL_SETTINGS, - value, - generator, - }); -}; - -/** - * operateChannelInfoRecord: Prepares record of entity 'CHANNEL_INFO' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateChannelInfoRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawChannelInfo; - const record = value.record as ChannelInfo; - const isCreateAction = action === OperationType.CREATE; - - const generator = (channelInfo: ChannelInfo) => { - channelInfo._raw.id = isCreateAction ? channelInfo.id : record?.id; - channelInfo.channelId = raw.channel_id; - channelInfo.guestCount = raw.guest_count; - channelInfo.header = raw.header; - channelInfo.memberCount = raw.member_count; - channelInfo.pinned_post_count = raw.pinned_post_count; - channelInfo.purpose = raw.purpose; - }; - - return operateBaseRecord({ - action, - database, - tableName: CHANNEL_INFO, - value, - generator, - }); -}; - -/** - * operateMyChannelRecord: Prepares record of entity 'MY_CHANNEL' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.database - * @param {MatchExistingRecord} operator.value - * @returns {Promise} - */ -export const operateMyChannelRecord = async ({action, database, value}: DataFactoryArgs) => { - const raw = value.raw as RawMyChannel; - const record = value.record as MyChannel; - const isCreateAction = action === OperationType.CREATE; - - const generator = (myChannel: MyChannel) => { - myChannel._raw.id = isCreateAction ? myChannel.id : record?.id; - myChannel.channelId = raw.channel_id; - myChannel.roles = raw.roles; - myChannel.messageCount = raw.message_count; - myChannel.mentionsCount = raw.mentions_count; - myChannel.lastPostAt = raw.last_post_at; - myChannel.lastViewedAt = raw.last_viewed_at; - }; - - return operateBaseRecord({ - action, - database, - tableName: MY_CHANNEL, - value, - generator, - }); -}; - -/** - * operateBaseRecord: This is the last step for each operator and depending on the 'action', it will either prepare an - * existing record for UPDATE or prepare a collection for CREATE - * - * @param {DataFactoryArgs} operatorBase - * @param {Database} operatorBase.database - * @param {string} operatorBase.tableName - * @param {MatchExistingRecord} operatorBase.value - * @param {((DataFactoryArgs) => void)} operatorBase.generator - * @returns {Promise} - */ -const operateBaseRecord = async ({action, database, tableName, value, generator}: DataFactoryArgs): Promise => { - if (action === OperationType.UPDATE) { - // Two possible scenarios: - // 1. We are dealing with either duplicates here and if so, we'll update instead of create - // 2. This is just a normal update operation - - const record = value.record as Model; - return record.prepareUpdate(() => generator!(record)); - } - - // Two possible scenarios - // 1. We don't have a record yet to update; so we create it - // 2. This is just a normal create operation - return database.collections.get(tableName!).prepareCreate(generator); -}; diff --git a/app/database/admin/data_operator/operators/test.ts b/app/database/admin/data_operator/operators/test.ts deleted file mode 100644 index efcf557aeb..0000000000 --- a/app/database/admin/data_operator/operators/test.ts +++ /dev/null @@ -1,944 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import DatabaseManager from '@database/admin/database_manager'; -import {DatabaseType, OperationType} from '@typings/database/enums'; - -import { - operateAppRecord, - operateChannelInfoRecord, - operateChannelMembershipRecord, - operateChannelRecord, - operateCustomEmojiRecord, - operateDraftRecord, - operateFileRecord, - operateGlobalRecord, - operateGroupMembershipRecord, - operateGroupRecord, - operateGroupsInChannelRecord, - operateGroupsInTeamRecord, - operateMyChannelRecord, - operateMyChannelSettingsRecord, - operateMyTeamRecord, - operatePostInThreadRecord, - operatePostMetadataRecord, - operatePostRecord, - operatePostsInChannelRecord, - operatePreferenceRecord, - operateReactionRecord, - operateRoleRecord, - operateServersRecord, - operateSlashCommandRecord, - operateSystemRecord, - operateTeamChannelHistoryRecord, - operateTeamMembershipRecord, - operateTeamRecord, - operateTeamSearchHistoryRecord, - operateTermsOfServiceRecord, - operateUserRecord, -} from './index'; - -jest.mock('@database/admin/database_manager'); - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -describe('*** DataOperator: Operators tests ***', () => { - const createConnection = async (setActive = false) => { - const dbName = 'server_schema_connection'; - const serverUrl = 'https://appv2.mattermost.com'; - const database = await DatabaseManager.createDatabaseConnection({ - shouldAddToDefaultDatabase: true, - configs: { - actionsEnabled: true, - dbName, - dbType: DatabaseType.SERVER, - serverUrl, - }, - }); - - if (setActive) { - await DatabaseManager.setActiveServerDatabase({ - displayName: dbName, - serverUrl, - }); - } - - return database; - }; - - it('=> operateAppRecord: should return an array of type App', async () => { - expect.assertions(3); - - const database = await DatabaseManager.getDefaultDatabase(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateAppRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - build_number: 'build-7', - created_at: 1, - version_number: 'v-1', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('App'); - }); - - it('=> operateGlobalRecord: should return an array of type Global', async () => { - expect.assertions(3); - - const database = await DatabaseManager.getDefaultDatabase(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateGlobalRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: {name: 'g-n1', value: 'g-v1'}, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Global'); - }); - - it('=> operateServersRecord: should return an array of type Servers', async () => { - expect.assertions(3); - - const database = await DatabaseManager.getDefaultDatabase(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateServersRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - db_path: 'mm-server', - display_name: 's-displayName', - mention_count: 1, - unread_count: 0, - url: 'https://community.mattermost.com', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Servers'); - }); - - it('=> operateRoleRecord: should return an array of type Role', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateRoleRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'role-1', - name: 'role-name-1', - permissions: [], - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Role'); - }); - - it('=> operateSystemRecord: should return an array of type System', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateSystemRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: {id: 'system-1', name: 'system-name-1', value: 'system'}, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('System'); - }); - - it('=> operateTermsOfServiceRecord: should return an array of type TermsOfService', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateTermsOfServiceRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'tos-1', - accepted_at: 1, - create_at: 1613667352029, - user_id: 'user1613667352029', - text: '', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe( - 'TermsOfService', - ); - }); - - it('=> operatePostRecord: should return an array of type Post', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operatePostRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: '8swgtrrdiff89jnsiwiip3y1eoe', - create_at: 1596032651748, - update_at: 1596032651748, - edit_at: 0, - delete_at: 0, - is_pinned: false, - user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', - channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', - root_id: 'ps81iqbesfby8jayz7owg4yypoo', - parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', - original_id: '', - message: 'Testing operator post', - type: '', - props: {}, - hashtags: '', - pending_post_id: '', - reply_count: 4, - last_reply_at: 0, - participants: null, - metadata: {}, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Post'); - }); - - it('=> operatePostInThreadRecord: should return an array of type PostsInThread', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operatePostInThreadRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'ps81iqbddesfby8jayz7owg4yypoo', - post_id: '8swgtrrdiff89jnsiwiip3y1eoe', - earliest: 1596032651748, - latest: 1597032651748, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe( - 'PostsInThread', - ); - }); - - it('=> operateReactionRecord: should return an array of type Reaction', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateReactionRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'ps81iqbddesfby8jayz7owg4yypoo', - user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - emoji_name: 'thumbsup', - create_at: 1596032651748, - update_at: 1608253011321, - delete_at: 0, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Reaction'); - }); - - it('=> operateFileRecord: should return an array of type File', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateFileRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - name: 'test_file', - extension: '.jpg', - size: 1000, - create_at: 1609253011321, - delete_at: 1609253011321, - height: 20, - update_at: 1609253011321, - user_id: 'wqyby5r5pinxxdqhoaomtacdhc', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('File'); - }); - - it('=> operatePostMetadataRecord: should return an array of type PostMetadata', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operatePostMetadataRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'ps81i4yypoo', - data: {}, - postId: 'ps81iqbddesfby8jayz7owg4yypoo', - type: 'opengraph', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('PostMetadata'); - }); - - it('=> operateDraftRecord: should return an array of type Draft', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateDraftRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'ps81i4yypoo', - root_id: 'ps81iqbddesfby8jayz7owg4yypoo', - message: 'draft message', - channel_id: 'channel_idp23232e', - files: [], - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Draft'); - }); - - it('=> operatePostsInChannelRecord: should return an array of type PostsInChannel', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operatePostsInChannelRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'ps81i4yypoo', - channel_id: 'channel_idp23232e', - earliest: 1608253011321, - latest: 1609253011321, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe( - 'PostsInChannel', - ); - }); - - it('=> operateUserRecord: should return an array of type User', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateUserRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: '9ciscaqbrpd6d8s68k76xb9bte', - is_bot: false, - create_at: 1599457495881, - update_at: 1607683720173, - delete_at: 0, - username: 'a.l', - auth_service: 'saml', - email: 'a.l@mattermost.com', - email_verified: true, - nickname: '', - first_name: 'A', - last_name: 'L', - position: 'Mobile Engineer', - roles: 'system_user', - props: {}, - notify_props: { - desktop: 'all', - desktop_sound: true, - email: true, - first_name: true, - mention_keys: '', - push: 'mention', - channel: true, - auto_responder_active: false, - auto_responder_message: 'Hello, I am out of office and unable to respond to messages.', - comments: 'never', - desktop_notification_sound: 'Hello', - push_status: 'online', - }, - last_password_update: 1604323112537, - last_picture_update: 1604686302260, - locale: 'en', - timezone: { - automaticTimezone: 'Indian/Mauritius', - manualTimezone: '', - useAutomaticTimezone: true, - }, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('User'); - }); - - it('=> operatePreferenceRecord: should return an array of type Preference', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operatePreferenceRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: {user_id: '9ciscaqbrpd6d8s68k76xb9bte', category: 'tutorial_step', name: '9ciscaqbrpd6d8s68k76xb9bte', value: '2'}, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Preference'); - }); - - it('=> operateTeamMembershipRecord: should return an array of type TeamMembership', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateTeamMembershipRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - team_id: 'a', - user_id: 'ab', - roles: '3ngdqe1e7tfcbmam4qgnxp91bw', - delete_at: 0, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('TeamMembership'); - }); - - it('=> operateCustomEmojiRecord: should return an array of type CustomEmoji', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateCustomEmojiRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'i', - create_at: 1580913641769, - update_at: 1580913641769, - delete_at: 0, - creator_id: '4cprpki7ri81mbx8efixcsb8jo', - name: 'boomI', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('CustomEmoji'); - }); - - it('=> operateGroupMembershipRecord: should return an array of type GroupMembership', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateGroupMembershipRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - user_id: 'u4cprpki7ri81mbx8efixcsb8jo', - group_id: 'g4cprpki7ri81mbx8efixcsb8jo', - - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('GroupMembership'); - }); - - it('=> operateChannelMembershipRecord: should return an array of type ChannelMembership', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateChannelMembershipRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - channel_id: '17bfnb1uwb8epewp4q3x3rx9go', - user_id: '9ciscaqbrpd6d8s68k76xb9bte', - roles: 'wqyby5r5pinxxdqhoaomtacdhc', - last_viewed_at: 1613667352029, - msg_count: 3864, - mention_count: 0, - notify_props: { - desktop: 'default', - email: 'default', - ignore_channel_mentions: 'default', - mark_unread: 'mention', - push: 'default', - }, - last_update_at: 1613667352029, - scheme_guest: false, - scheme_user: true, - scheme_admin: false, - explicit_roles: '', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('ChannelMembership'); - }); - - it('=> operateGroupRecord: should return an array of type Group', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateGroupRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'id_groupdfjdlfkjdkfdsf', - name: 'mobile_team', - display_name: 'mobile team', - description: '', - source: '', - remote_id: '', - create_at: 0, - update_at: 0, - delete_at: 0, - has_syncables: true, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Group'); - }); - - it('=> operateGroupsInTeamRecord: should return an array of type GroupsInTeam', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateGroupsInTeamRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - team_id: 'team_89', - team_display_name: '', - team_type: '', - group_id: 'group_id89', - auto_add: true, - create_at: 0, - delete_at: 0, - update_at: 0, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('GroupsInTeam'); - }); - - it('=> operateGroupsInChannelRecord: should return an array of type GroupsInChannel', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateGroupsInChannelRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - auto_add: true, - channel_display_name: '', - channel_id: 'channelid', - channel_type: '', - create_at: 0, - delete_at: 0, - group_id: 'groupId', - team_display_name: '', - team_id: '', - team_type: '', - update_at: 0, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('GroupsInChannel'); - }); - - it('=> operateTeamRecord: should return an array of type Team', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateTeamRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby', - create_at: 1445538153952, - update_at: 1588876392150, - delete_at: 0, - display_name: 'Contributors', - name: 'core', - description: '', - email: '', - type: 'O', - company_name: '', - allowed_domains: '', - invite_id: 'codoy5s743rq5mk18i7u5dfdfksz7e', - allow_open_invite: true, - last_team_icon_update: 1525181587639, - scheme_id: 'hbwgrncq1pfcdkpotzidfdmarn95o', - group_constrained: null, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Team'); - }); - - it('=> operateTeamChannelHistoryRecord: should return an array of type Team', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateTeamChannelHistoryRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - team_id: 'a', - channel_ids: ['ca', 'cb'], - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('TeamChannelHistory'); - }); - - it('=> operateTeamSearchHistoryRecord: should return an array of type TeamSearchHistory', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateTeamSearchHistoryRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - team_id: 'a', - term: 'termA', - display_term: 'termA', - created_at: 1445538153952, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('TeamSearchHistory'); - }); - - it('=> operateSlashCommandRecord: should return an array of type SlashCommand', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateSlashCommandRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'command_1', - auto_complete: true, - auto_complete_desc: 'mock_command', - auto_complete_hint: 'hint', - create_at: 1445538153952, - creator_id: 'creator_id', - delete_at: 1445538153952, - description: 'description', - display_name: 'display_name', - icon_url: 'display_name', - method: 'get', - team_id: 'teamA', - token: 'token', - trigger: 'trigger', - update_at: 1445538153953, - url: 'url', - username: 'userA', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('SlashCommand'); - }); - - it('=> operateMyTeamRecord: should return an array of type MyTeam', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateMyTeamRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - team_id: 'teamA', - roles: 'roleA, roleB, roleC', - is_unread: true, - mentions_count: 3, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('MyTeam'); - }); - - it('=> operateChannelRecord: should return an array of type Channel', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateChannelRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'kow9j1ttnxwig7tnqgebg7dtipno', - create_at: 1600185541285, - update_at: 1604401077256, - delete_at: 0, - team_id: '', - type: 'D', - display_name: '', - name: 'jui1zkzkhh357b4bejephjz5u8daw__9ciscaqbrpd6d8s68k76xb9bte', - header: 'https://mattermost)', - purpose: '', - last_post_at: 1617311494451, - total_msg_count: 585, - extra_update_at: 0, - creator_id: '', - scheme_id: null, - props: null, - group_constrained: null, - shared: null, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('Channel'); - }); - - it('=> operateMyChannelSettingsRecord: should return an array of type MyChannelSettings', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateMyChannelSettingsRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - channel_id: 'c', - notify_props: { - desktop: 'all', - desktop_sound: true, - email: true, - first_name: true, - mention_keys: '', - push: 'mention', - channel: true, - }, - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('MyChannelSettings'); - }); - - it('=> operateChannelInfoRecord: should return an array of type ChannelInfo', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateChannelInfoRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - channel_id: 'c', - guest_count: 10, - header: 'channel info header', - member_count: 10, - pinned_post_count: 3, - purpose: 'sample channel ', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('ChannelInfo'); - }); - - it('=> operateMyChannelRecord: should return an array of type MyChannel', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateMyChannelRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - channel_id: 'cd', - last_post_at: 1617311494451, - last_viewed_at: 1617311494451, - mentions_count: 3, - message_count: 10, - roles: 'guest', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('MyChannel'); - }); -}); diff --git a/app/database/admin/data_operator/utils/index.ts b/app/database/admin/data_operator/utils/index.ts deleted file mode 100644 index 3429420554..0000000000 --- a/app/database/admin/data_operator/utils/index.ts +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Q} from '@nozbe/watermelondb'; -import Model from '@nozbe/watermelondb/Model'; - -import {MM_TABLES} from '@constants/database'; -import Channel from '@typings/database/channel'; -import { - ChainPostsArgs, - IdenticalRecordArgs, - MatchExistingRecord, - RangeOfValueArgs, - RawChannel, - RawPost, - RawReaction, - RawSlashCommand, - RawTeam, - RawUser, - RawValue, - RecordPair, - RetrieveRecordsArgs, - SanitizePostsArgs, - SanitizeReactionsArgs, -} from '@typings/database/database'; -import Reaction from '@typings/database/reaction'; -import Post from '@typings/database/post'; -import SlashCommand from '@typings/database/slash_command'; -import Team from '@typings/database/team'; -import User from '@typings/database/user'; - -const {CHANNEL, POST, REACTION, SLASH_COMMAND, TEAM, USER} = MM_TABLES.SERVER; - -/** - * sanitizePosts: Creates arrays of ordered and unordered posts. Unordered posts are those posts that are not - * present in the orders array - * @param {SanitizePostsArgs} sanitizePosts - * @param {RawPost[]} sanitizePosts.posts - * @param {string[]} sanitizePosts.orders - */ -export const sanitizePosts = ({posts, orders}: SanitizePostsArgs) => { - const orderedPosts:RawPost[] = []; - const unOrderedPosts:RawPost[] = []; - - posts.forEach((post) => { - if (post?.id && orders.includes(post.id)) { - orderedPosts.push(post); - } else { - unOrderedPosts.push(post); - } - }); - - return { - postsOrdered: orderedPosts, - postsUnordered: unOrderedPosts, - }; -}; - -/** - * createPostsChain: Basically creates the 'chain of posts' using the 'orders' array; each post is linked to the other - * by the previous_post_id field. - * @param {ChainPostsArgs} chainPosts - * @param {string[]} chainPosts.orders - * @param {RawPost[]} chainPosts.rawPosts - * @param {string} chainPosts.previousPostId - * @returns {RawPost[]} - */ -export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainPostsArgs) => { - const posts: MatchExistingRecord[] = []; - - rawPosts.forEach((post) => { - const postId = post.id; - const orderIndex = orders.findIndex((order) => { - return order === postId; - }); - - if (orderIndex === -1) { - // This case will not occur as we are using 'ordered' posts for this step. However, if this happens, that - // implies that we might be dealing with an unordered post and in which case we do not action on it. - } else if (orderIndex === 0) { - posts.push({record: undefined, raw: {...post, prev_post_id: previousPostId}}); - } else { - posts.push({record: undefined, raw: {...post, prev_post_id: orders[orderIndex - 1]}}); - } - }); - - return posts; -}; - -/** - * sanitizeReactions: Treats reactions happening on a Post. For example, a user can add/remove an emoji. Hence, this function - * tell us which reactions to create/delete in the Reaction table and which custom-emoji to create in our database. - * For more information, please have a look at https://community.mattermost.com/core/pl/rq9e8jnonpyrmnyxpuzyc4d6ko - * @param {SanitizeReactionsArgs} sanitizeReactions - * @param {Database} sanitizeReactions.database - * @param {string} sanitizeReactions.post_id - * @param {RawReaction[]} sanitizeReactions.rawReactions - * @returns {Promise<{createReactions: RawReaction[], createEmojis: {name: string}[], deleteReactions: Reaction[]}>} - */ -export const sanitizeReactions = async ({database, post_id, rawReactions}: SanitizeReactionsArgs) => { - const reactions = (await database.collections. - get(REACTION). - query(Q.where('post_id', post_id)). - fetch()) as Reaction[]; - - // similarObjects: Contains objects that are in both the RawReaction array and in the Reaction entity - const similarObjects: Reaction[] = []; - - const createReactions: MatchExistingRecord[] = []; - - const emojiSet = new Set(); - - for (let i = 0; i < rawReactions.length; i++) { - const rawReaction = rawReactions[i] as RawReaction; - - // Do we have a similar value of rawReaction in the REACTION table? - const idxPresent = reactions.findIndex((value) => { - return ( - value.userId === rawReaction.user_id && - value.emojiName === rawReaction.emoji_name - ); - }); - - if (idxPresent === -1) { - // So, we don't have a similar Reaction object. That one is new...so we'll create it - createReactions.push({record: undefined, raw: rawReaction}); - - // If that reaction is new, that implies that the emoji might also be new - emojiSet.add(rawReaction.emoji_name); - } else { - // we have a similar object in both reactions and rawReactions; we'll pop it out from both arrays - similarObjects.push(reactions[idxPresent]); - } - } - - // finding out elements to delete using array subtract - const deleteReactions = reactions. - filter((reaction) => !similarObjects.includes(reaction)). - map((outCast) => outCast.prepareDestroyPermanently()); - - const createEmojis = Array.from(emojiSet).map((emoji) => { - return {name: emoji}; - }); - - return {createReactions, createEmojis, deleteReactions}; -}; - -/** - * retrieveRecords: Retrieves records from the database - * @param {RetrieveRecordsArgs} records - * @param {Database} records.database - * @param {string} records.tableName - * @param {any} records.condition - * @returns {Promise} - */ -export const retrieveRecords = async ({database, tableName, condition}: RetrieveRecordsArgs) => { - const records = (await database.collections.get(tableName).query(condition).fetch()) as Model[]; - return records; -}; - -/** - * hasSimilarUpdateAt: Database Operations on some entities are expensive. As such, we would like to operate if and only if we are - * 100% sure that the records are actually different from what we already have in the database. - * @param {IdenticalRecordArgs} identicalRecord - * @param {string} identicalRecord.tableName - * @param {RecordValue} identicalRecord.newValue - * @param {Model} identicalRecord.existingRecord - * @returns {boolean} - */ -export const hasSimilarUpdateAt = ({tableName, newValue, existingRecord}: IdenticalRecordArgs) => { - const guardTables = [CHANNEL, POST, SLASH_COMMAND, TEAM, USER]; - - if (guardTables.includes(tableName)) { - type Raw = RawPost | RawUser | RawTeam | RawSlashCommand | RawChannel - type ExistingRecord = Post | User | Team | SlashCommand | Channel - - return (newValue as Raw).update_at === (existingRecord as ExistingRecord).updateAt; - } - return false; -}; - -/** - * This method extracts one particular field 'fieldName' from the raw values and returns them as a string array - * @param {RangeOfValueArgs} range - * @param {string} range.fieldName - * @param {RawValue[]} range.raws - * @returns {string[]} - */ -export const getRangeOfValues = ({fieldName, raws}: RangeOfValueArgs) => { - return raws.reduce((oneOfs, current: RawValue) => { - const key = fieldName as keyof typeof current; - const value: string = current[key] as string; - if (value) { - oneOfs.push(value); - } - return oneOfs; - }, [] as string[]); -}; - -/** - * getRawRecordPairs: Utility method that maps over the raws array to create an array of RecordPair - * @param {any[]} raws - * @returns {{record: undefined, raw: any}[]} - */ -export const getRawRecordPairs = (raws: any[]): RecordPair[] => { - return raws.map((raw) => { - return {raw, record: undefined}; - }); -}; - -/** - * getUniqueRawsBy: We have to ensure that we are not updating the same record twice in the same operation. - * Hence, thought it might not occur, prevention is better than cure. This function removes duplicates from the 'raws' array. - * @param {RawValue[]} raws - * @param {string} key - */ -export const getUniqueRawsBy = ({raws, key}:{ raws: RawValue[], key: string}) => { - return [...new Map(raws.map((item) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const curItemKey = item[key]; - return [curItemKey, item]; - })).values()]; -}; diff --git a/app/database/admin/exceptions/data_operator_exception.ts b/app/database/exceptions/data_operator_exception.ts similarity index 100% rename from app/database/admin/exceptions/data_operator_exception.ts rename to app/database/exceptions/data_operator_exception.ts diff --git a/app/database/admin/exceptions/database_connection_exception.ts b/app/database/exceptions/database_connection_exception.ts similarity index 100% rename from app/database/admin/exceptions/database_connection_exception.ts rename to app/database/exceptions/database_connection_exception.ts diff --git a/app/database/admin/database_manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts similarity index 96% rename from app/database/admin/database_manager/__mocks__/index.ts rename to app/database/manager/__mocks__/index.ts index b9e97f18ec..23211b8152 100644 --- a/app/database/admin/database_manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -5,10 +5,10 @@ import {Database, Q} from '@nozbe/watermelondb'; import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'; import {MM_TABLES} from '@constants/database'; -import DefaultMigration from '@database/default/migration'; -import {App, Global, Servers} from '@database/default/models'; -import {defaultSchema} from '@database/default/schema'; -import ServerMigration from '@database/server/migration'; +import DefaultMigration from '@database/migration/default'; +import {App, Global, Servers} from '@database/models/default'; +import {defaultSchema} from '@database/schema/default'; +import ServerMigration from '@database/migration/server'; import { Channel, ChannelInfo, @@ -38,8 +38,8 @@ import { TeamSearchHistory, TermsOfService, User, -} from '@database/server/models'; -import {serverSchema} from '@database/server/schema'; +} from '@database/models/server'; +import {serverSchema} from '@database/schema/server'; import logger from '@nozbe/watermelondb/utils/common/logger'; import type { ActiveServerDatabaseArgs, @@ -148,7 +148,7 @@ class DatabaseManager { return new Database({adapter, actionsEnabled, modelClasses}); } catch (e) { // eslint-disable-next-line no-console - console.log('ERROR ==========================\n', e); + console.log('createDatabaseConnection ERROR:', e); } return undefined; @@ -180,7 +180,7 @@ class DatabaseManager { * @param {String} serverUrl * @returns {Promise} */ - isServerPresent = async (serverUrl: String) => { + isServerPresent = async (serverUrl: string) => { const allServers = await this.getAllServers(); const existingServer = allServers?.filter((server) => { return server.url === serverUrl; diff --git a/app/database/admin/database_manager/index.ts b/app/database/manager/index.ts similarity index 97% rename from app/database/admin/database_manager/index.ts rename to app/database/manager/index.ts index a797bae7ba..41b95e5318 100644 --- a/app/database/admin/database_manager/index.ts +++ b/app/database/manager/index.ts @@ -8,10 +8,10 @@ import {DeviceEventEmitter, Platform} from 'react-native'; import {FileSystem} from 'react-native-unimodules'; import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; -import DefaultMigration from '@database/default/migration'; -import {App, Global, Servers} from '@database/default/models'; -import {defaultSchema} from '@database/default/schema'; -import ServerMigration from '@database/server/migration'; +import DefaultMigration from '@database/migration/default'; +import {App, Global, Servers} from '@database/models/default'; +import {defaultSchema} from '@database/schema/default'; +import ServerMigration from '@database/migration/server'; import { Channel, ChannelInfo, @@ -41,8 +41,8 @@ import { TeamSearchHistory, TermsOfService, User, -} from '@database/server/models'; -import {serverSchema} from '@database/server/schema'; +} from '@database/models/server'; +import {serverSchema} from '@database/schema/server'; import type { ActiveServerDatabaseArgs, DatabaseConnectionArgs, @@ -176,7 +176,7 @@ class DatabaseManager { * @param {String} serverUrl * @returns {Promise} */ - private isServerPresent = async (serverUrl: String) => { + private isServerPresent = async (serverUrl: string) => { const allServers = await this.getAllServers(); const existingServer = allServers?.filter((server) => { diff --git a/app/database/admin/database_manager/test.ts b/app/database/manager/test.ts similarity index 98% rename from app/database/admin/database_manager/test.ts rename to app/database/manager/test.ts index ff9f585942..b419f46524 100644 --- a/app/database/admin/database_manager/test.ts +++ b/app/database/manager/test.ts @@ -8,9 +8,9 @@ import {DatabaseInstance} from '@typings/database/database'; import {DatabaseType} from '@typings/database/enums'; import IServers from '@typings/database/servers'; -import DatabaseManager from './index'; +import DatabaseManager from '@database/manager'; -jest.mock('./index'); +jest.mock('@database/manager'); const {SERVERS} = MM_TABLES.DEFAULT; @@ -27,6 +27,7 @@ describe('*** Database Manager tests ***', () => { const spyOnAddServerToDefaultDatabase = jest.spyOn(DatabaseManager as any, 'addServerToDefaultDatabase'); const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeInstanceOf(Database); expect(spyOnAddServerToDefaultDatabase).not.toHaveBeenCalledTimes(1); }); diff --git a/app/database/admin/database_manager/test_manual.ts b/app/database/manager/test_manual.ts similarity index 100% rename from app/database/admin/database_manager/test_manual.ts rename to app/database/manager/test_manual.ts diff --git a/app/database/default/migration/index.ts b/app/database/migration/default/index.ts similarity index 100% rename from app/database/default/migration/index.ts rename to app/database/migration/default/index.ts diff --git a/app/database/server/migration/index.ts b/app/database/migration/server/index.ts similarity index 100% rename from app/database/server/migration/index.ts rename to app/database/migration/server/index.ts diff --git a/app/database/default/models/app.ts b/app/database/models/default/app.ts similarity index 100% rename from app/database/default/models/app.ts rename to app/database/models/default/app.ts diff --git a/app/database/default/models/global.ts b/app/database/models/default/global.ts similarity index 100% rename from app/database/default/models/global.ts rename to app/database/models/default/global.ts diff --git a/app/database/default/models/index.ts b/app/database/models/default/index.ts similarity index 100% rename from app/database/default/models/index.ts rename to app/database/models/default/index.ts diff --git a/app/database/default/models/servers.ts b/app/database/models/default/servers.ts similarity index 100% rename from app/database/default/models/servers.ts rename to app/database/models/default/servers.ts diff --git a/app/database/server/models/channel.ts b/app/database/models/server/channel.ts similarity index 100% rename from app/database/server/models/channel.ts rename to app/database/models/server/channel.ts diff --git a/app/database/server/models/channel_info.ts b/app/database/models/server/channel_info.ts similarity index 99% rename from app/database/server/models/channel_info.ts rename to app/database/models/server/channel_info.ts index 4b14b85abb..1ea1669fc6 100644 --- a/app/database/server/models/channel_info.ts +++ b/app/database/models/server/channel_info.ts @@ -45,5 +45,5 @@ export default class ChannelInfo extends Model { @field('purpose') purpose!: string; /** channel : The lazy query property to the record from entity CHANNEL */ - @immutableRelation(CHANNEL, 'channel_id') channel!: Relation + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; } diff --git a/app/database/server/models/channel_membership.ts b/app/database/models/server/channel_membership.ts similarity index 94% rename from app/database/server/models/channel_membership.ts rename to app/database/models/server/channel_membership.ts index fdf287e0f1..319fcdf2f8 100644 --- a/app/database/server/models/channel_membership.ts +++ b/app/database/models/server/channel_membership.ts @@ -44,10 +44,10 @@ export default class ChannelMembership extends Model { /** * getAllChannelsForUser - Retrieves all the channels that the user is part of */ - @lazy getAllChannelsForUser = this.collections.get(CHANNEL).query(Q.on(USER, 'id', this.userId)) as Query + @lazy getAllChannelsForUser = this.collections.get(CHANNEL).query(Q.on(USER, 'id', this.userId)) as Query; /** * getAllUsersInChannel - Retrieves all the users who are part of this channel */ - @lazy getAllUsersInChannel = this.collections.get(USER).query(Q.on(CHANNEL, 'id', this.channelId)) as Query + @lazy getAllUsersInChannel = this.collections.get(USER).query(Q.on(CHANNEL, 'id', this.channelId)) as Query; } diff --git a/app/database/server/models/custom_emoji.ts b/app/database/models/server/custom_emoji.ts similarity index 100% rename from app/database/server/models/custom_emoji.ts rename to app/database/models/server/custom_emoji.ts diff --git a/app/database/server/models/draft.ts b/app/database/models/server/draft.ts similarity index 100% rename from app/database/server/models/draft.ts rename to app/database/models/server/draft.ts diff --git a/app/database/server/models/file.ts b/app/database/models/server/file.ts similarity index 100% rename from app/database/server/models/file.ts rename to app/database/models/server/file.ts diff --git a/app/database/server/models/group.ts b/app/database/models/server/group.ts similarity index 100% rename from app/database/server/models/group.ts rename to app/database/models/server/group.ts diff --git a/app/database/server/models/group_membership.ts b/app/database/models/server/group_membership.ts similarity index 95% rename from app/database/server/models/group_membership.ts rename to app/database/models/server/group_membership.ts index dd270c6dbc..52e92e673f 100644 --- a/app/database/server/models/group_membership.ts +++ b/app/database/models/server/group_membership.ts @@ -44,10 +44,10 @@ export default class GroupMembership extends Model { /** * getAllGroupsForUser : Retrieves all the groups that the user is part of */ - @lazy getAllGroupsForUser = this.collections.get(GROUP).query(Q.on(USER, 'id', this.userId)) as Query + @lazy getAllGroupsForUser = this.collections.get(GROUP).query(Q.on(USER, 'id', this.userId)) as Query; /** * getAllUsersInGroup : Retrieves all the users who are part of this group */ - @lazy getAllUsersInGroup = this.collections.get(USER).query(Q.on(GROUP, 'id', this.groupId)) as Query + @lazy getAllUsersInGroup = this.collections.get(USER).query(Q.on(GROUP, 'id', this.groupId)) as Query; } diff --git a/app/database/server/models/groups_in_channel.ts b/app/database/models/server/groups_in_channel.ts similarity index 100% rename from app/database/server/models/groups_in_channel.ts rename to app/database/models/server/groups_in_channel.ts diff --git a/app/database/server/models/groups_in_team.ts b/app/database/models/server/groups_in_team.ts similarity index 87% rename from app/database/server/models/groups_in_team.ts rename to app/database/models/server/groups_in_team.ts index 8cad23f100..bced43d27d 100644 --- a/app/database/server/models/groups_in_team.ts +++ b/app/database/models/server/groups_in_team.ts @@ -31,15 +31,9 @@ export default class GroupsInTeam extends Model { /** group_id : The foreign key to the related Group record */ @field('group_id') groupId!: string; - /** member_count : The number of users in that group */ - @field('member_count') memberCount!: number; - /** team_id : The foreign key to the related Team record */ @field('team_id') teamId!: string; - /** timezone_count : The number of timezones */ - @field('timezone_count') timezoneCount!: number; - /** team : The related record to the parent Team model */ @immutableRelation(TEAM, 'team_id') team!: Relation; diff --git a/app/database/server/models/index.ts b/app/database/models/server/index.ts similarity index 100% rename from app/database/server/models/index.ts rename to app/database/models/server/index.ts diff --git a/app/database/server/models/my_channel.ts b/app/database/models/server/my_channel.ts similarity index 99% rename from app/database/server/models/my_channel.ts rename to app/database/models/server/my_channel.ts index b890cb5dc8..ed91ec627d 100644 --- a/app/database/server/models/my_channel.ts +++ b/app/database/models/server/my_channel.ts @@ -43,5 +43,5 @@ export default class MyChannel extends Model { @field('roles') roles!: string; /** channel : The relation pointing to entity CHANNEL */ - @immutableRelation(CHANNEL, 'channel_id') channel!: Relation + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; } diff --git a/app/database/server/models/my_channel_settings.ts b/app/database/models/server/my_channel_settings.ts similarity index 100% rename from app/database/server/models/my_channel_settings.ts rename to app/database/models/server/my_channel_settings.ts diff --git a/app/database/server/models/my_team.ts b/app/database/models/server/my_team.ts similarity index 96% rename from app/database/server/models/my_team.ts rename to app/database/models/server/my_team.ts index 332ec0d256..85177fecfe 100644 --- a/app/database/server/models/my_team.ts +++ b/app/database/models/server/my_team.ts @@ -37,5 +37,5 @@ export default class MyTeam extends Model { @field('team_id') teamId!: string; /** team : The relation to the entity TEAM, that this user belongs to */ - @relation(MY_TEAM, 'team_id') team!: Relation + @relation(MY_TEAM, 'team_id') team!: Relation; } diff --git a/app/database/server/models/post.ts b/app/database/models/server/post.ts similarity index 100% rename from app/database/server/models/post.ts rename to app/database/models/server/post.ts diff --git a/app/database/server/models/post_metadata.ts b/app/database/models/server/post_metadata.ts similarity index 100% rename from app/database/server/models/post_metadata.ts rename to app/database/models/server/post_metadata.ts diff --git a/app/database/server/models/posts_in_channel.ts b/app/database/models/server/posts_in_channel.ts similarity index 100% rename from app/database/server/models/posts_in_channel.ts rename to app/database/models/server/posts_in_channel.ts diff --git a/app/database/server/models/posts_in_thread.ts b/app/database/models/server/posts_in_thread.ts similarity index 100% rename from app/database/server/models/posts_in_thread.ts rename to app/database/models/server/posts_in_thread.ts diff --git a/app/database/server/models/preference.ts b/app/database/models/server/preference.ts similarity index 100% rename from app/database/server/models/preference.ts rename to app/database/models/server/preference.ts diff --git a/app/database/server/models/reaction.ts b/app/database/models/server/reaction.ts similarity index 100% rename from app/database/server/models/reaction.ts rename to app/database/models/server/reaction.ts diff --git a/app/database/server/models/role.ts b/app/database/models/server/role.ts similarity index 100% rename from app/database/server/models/role.ts rename to app/database/models/server/role.ts diff --git a/app/database/server/models/slash_command.ts b/app/database/models/server/slash_command.ts similarity index 100% rename from app/database/server/models/slash_command.ts rename to app/database/models/server/slash_command.ts diff --git a/app/database/server/models/system.ts b/app/database/models/server/system.ts similarity index 100% rename from app/database/server/models/system.ts rename to app/database/models/server/system.ts diff --git a/app/database/server/models/team.ts b/app/database/models/server/team.ts similarity index 100% rename from app/database/server/models/team.ts rename to app/database/models/server/team.ts diff --git a/app/database/server/models/team_channel_history.ts b/app/database/models/server/team_channel_history.ts similarity index 100% rename from app/database/server/models/team_channel_history.ts rename to app/database/models/server/team_channel_history.ts diff --git a/app/database/server/models/team_membership.ts b/app/database/models/server/team_membership.ts similarity index 95% rename from app/database/server/models/team_membership.ts rename to app/database/models/server/team_membership.ts index 8bbd701d95..13099c88a4 100644 --- a/app/database/server/models/team_membership.ts +++ b/app/database/models/server/team_membership.ts @@ -44,10 +44,10 @@ export default class TeamMembership extends Model { /** * getAllTeamsForUser - Retrieves all the teams that the user is part of */ - @lazy getAllTeamsForUser = this.collections.get(TEAM).query(Q.on(USER, 'id', this.userId)) as Query + @lazy getAllTeamsForUser = this.collections.get(TEAM).query(Q.on(USER, 'id', this.userId)) as Query; /** * getAllUsersInTeam - Retrieves all the users who are part of this team */ - @lazy getAllUsersInTeam = this.collections.get(USER).query(Q.on(TEAM, 'id', this.teamId)) as Query + @lazy getAllUsersInTeam = this.collections.get(USER).query(Q.on(TEAM, 'id', this.teamId)) as Query; } diff --git a/app/database/server/models/team_search_history.ts b/app/database/models/server/team_search_history.ts similarity index 100% rename from app/database/server/models/team_search_history.ts rename to app/database/models/server/team_search_history.ts diff --git a/app/database/server/models/terms_of_service.ts b/app/database/models/server/terms_of_service.ts similarity index 100% rename from app/database/server/models/terms_of_service.ts rename to app/database/models/server/terms_of_service.ts diff --git a/app/database/server/models/user.ts b/app/database/models/server/user.ts similarity index 100% rename from app/database/server/models/user.ts rename to app/database/models/server/user.ts diff --git a/app/database/admin/data_operator/comparators/index.ts b/app/database/operator/comparators/index.ts similarity index 97% rename from app/database/admin/data_operator/comparators/index.ts rename to app/database/operator/comparators/index.ts index a7887b7560..2e1265bfbd 100644 --- a/app/database/admin/data_operator/comparators/index.ts +++ b/app/database/operator/comparators/index.ts @@ -64,11 +64,7 @@ import User from '@typings/database/user'; */ export const isRecordAppEqualToRaw = (record: App, raw: RawApp) => { - return ( - raw.build_number === record.buildNumber && - raw.created_at === record.createdAt && - raw.version_number === record.versionNumber - ); + return (raw.build_number === record.buildNumber && raw.version_number === record.versionNumber); }; export const isRecordGlobalEqualToRaw = (record: Global, raw: RawGlobal) => { diff --git a/app/database/operator/handlers/base_handler.test.ts b/app/database/operator/handlers/base_handler.test.ts new file mode 100644 index 0000000000..2823d9d30f --- /dev/null +++ b/app/database/operator/handlers/base_handler.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import DatabaseManager from '@database/manager'; +import {DataOperator} from '@database/operator'; +import { + isRecordAppEqualToRaw, + isRecordCustomEmojiEqualToRaw, + isRecordGlobalEqualToRaw, + isRecordRoleEqualToRaw, + isRecordServerEqualToRaw, + isRecordSystemEqualToRaw, + isRecordTermsOfServiceEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareAppRecord, + prepareCustomEmojiRecord, + prepareGlobalRecord, + prepareRoleRecord, + prepareServersRecord, + prepareSystemRecord, + prepareTermsOfServiceRecord, +} from '@database/operator/prepareRecords/general'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {RawGlobal, RawRole, RawServers, RawTermsOfService} from '@typings/database/database'; +import {IsolatedEntities} from '@typings/database/enums'; + +jest.mock('@database/manager'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** DataOperator: Base Handlers tests ***', () => { + it('=> HandleApp: should write to APP entity', async () => { + expect.assertions(3); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.APP, + values: [ + { + build_number: 'build-10x', + created_at: 1, + version_number: 'version-10', + }, + { + build_number: 'build-11y', + created_at: 1, + version_number: 'version-11', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'version_number', + operator: prepareAppRecord, + findMatchingRecordBy: isRecordAppEqualToRaw, + rawValues: [ + { + build_number: 'build-10x', + created_at: 1, + version_number: 'version-10', + }, + { + build_number: 'build-11y', + created_at: 1, + version_number: 'version-11', + }, + ], + tableName: 'app', + prepareRecordsOnly: false, + }); + }); + + it('=> HandleGlobal: should write to GLOBAL entity', async () => { + expect.assertions(2); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values: RawGlobal[] = [{name: 'global-1-name', value: 'global-1-value'}]; + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.GLOBAL, + values, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + findMatchingRecordBy: isRecordGlobalEqualToRaw, + fieldName: 'name', + operator: prepareGlobalRecord, + rawValues: values, + tableName: 'global', + prepareRecordsOnly: false, + }); + }); + + it('=> HandleServers: should write to SERVERS entity', async () => { + expect.assertions(2); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + const values: RawServers[] = [ + { + db_path: 'server.db', + display_name: 'community', + mention_count: 0, + unread_count: 0, + url: 'https://community.mattermost.com', + }, + ]; + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.SERVERS, + values, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'url', + operator: prepareServersRecord, + findMatchingRecordBy: isRecordServerEqualToRaw, + rawValues: [ + { + db_path: 'server.db', + display_name: 'community', + mention_count: 0, + unread_count: 0, + url: 'https://community.mattermost.com', + }, + ], + tableName: 'servers', + prepareRecordsOnly: false, + }); + }); + + it('=> HandleRole: should write to ROLE entity', async () => { + expect.assertions(1); + + await createTestConnection({databaseName: 'base_handler', setActive: true}); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + const values: RawRole[] = [ + { + id: 'custom-emoji-id-1', + name: 'custom-emoji-1', + permissions: ['custom-emoji-1'], + }, + ]; + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.ROLE, + values, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'id', + operator: prepareRoleRecord, + findMatchingRecordBy: isRecordRoleEqualToRaw, + rawValues: [ + { + id: 'custom-emoji-id-1', + name: 'custom-emoji-1', + permissions: ['custom-emoji-1'], + }, + ], + tableName: 'Role', + prepareRecordsOnly: false, + }); + }); + + it('=> HandleCustomEmojis: should write to CUSTOM_EMOJI entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'base_handler', setActive: true}); + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.CUSTOM_EMOJI, + values: [ + { + id: 'i', + create_at: 1580913641769, + update_at: 1580913641769, + delete_at: 0, + creator_id: '4cprpki7ri81mbx8efixcsb8jo', + name: 'boomI', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'id', + rawValues: [ + { + id: 'i', + create_at: 1580913641769, + update_at: 1580913641769, + delete_at: 0, + creator_id: '4cprpki7ri81mbx8efixcsb8jo', + name: 'boomI', + }, + ], + tableName: 'CustomEmoji', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordCustomEmojiEqualToRaw, + operator: prepareCustomEmojiRecord, + }); + }); + + it('=> HandleSystem: should write to SYSTEM entity', async () => { + expect.assertions(1); + + await createTestConnection({databaseName: 'base_handler', setActive: true}); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + const values = [{id: 'system-id-1', name: 'system-1', value: 'system-1'}]; + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.SYSTEM, + values, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + findMatchingRecordBy: isRecordSystemEqualToRaw, + fieldName: 'id', + operator: prepareSystemRecord, + rawValues: values, + tableName: 'System', + prepareRecordsOnly: false, + }); + }); + + it('=> HandleTermsOfService: should write to TERMS_OF_SERVICE entity', async () => { + expect.assertions(1); + + await createTestConnection({databaseName: 'base_handler', setActive: true}); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + const values: RawTermsOfService[] = [ + { + id: 'tos-1', + accepted_at: 1, + create_at: 1613667352029, + user_id: 'user1613667352029', + text: '', + }, + ]; + + await DataOperator.handleIsolatedEntity({ + tableName: IsolatedEntities.TERMS_OF_SERVICE, + values, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + findMatchingRecordBy: isRecordTermsOfServiceEqualToRaw, + fieldName: 'id', + operator: prepareTermsOfServiceRecord, + rawValues: values, + tableName: 'TermsOfService', + prepareRecordsOnly: false, + }); + }); + + it('=> No table name: should not call executeInDatabase if tableName is invalid', async () => { + expect.assertions(2); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + await expect( + DataOperator.handleIsolatedEntity({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + tableName: 'INVALID_TABLE_NAME', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + values: [{id: 'tos-1', accepted_at: 1}], + }), + ).rejects.toThrow(DataOperatorException); + }); +}); diff --git a/app/database/operator/handlers/base_handler.ts b/app/database/operator/handlers/base_handler.ts new file mode 100644 index 0000000000..ba3062c732 --- /dev/null +++ b/app/database/operator/handlers/base_handler.ts @@ -0,0 +1,456 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database, Q} from '@nozbe/watermelondb'; +import Model from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import DatabaseConnectionException from '@database/exceptions/database_connection_exception'; +import DatabaseManager from '@database/manager'; +import { + isRecordAppEqualToRaw, + isRecordCustomEmojiEqualToRaw, + isRecordGlobalEqualToRaw, + isRecordRoleEqualToRaw, + isRecordServerEqualToRaw, + isRecordSystemEqualToRaw, + isRecordTermsOfServiceEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareAppRecord, + prepareCustomEmojiRecord, + prepareGlobalRecord, + prepareRoleRecord, + prepareServersRecord, + prepareSystemRecord, + prepareTermsOfServiceRecord, +} from '@database/operator/prepareRecords/general'; +import { + getRangeOfValues, + getRawRecordPairs, + getUniqueRawsBy, + hasSimilarUpdateAt, + retrieveRecords, +} from '@database/operator/utils/general'; +import { + BatchOperationsArgs, + DatabaseInstance, + HandleEntityRecordsArgs, + HandleIsolatedEntityArgs, + PrepareForDatabaseArgs, + PrepareRecordsArgs, + ProcessInputsArgs, + RawValue, + RecordPair, +} from '@typings/database/database'; +import {IsolatedEntities, OperationType} from '@typings/database/enums'; + +export interface BaseHandlerMix { + activeDatabase: Database; + getActiveDatabase: () => DatabaseInstance; + setActiveDatabase: (database: Database) => void; + handleIsolatedEntity: ({tableName, values, prepareRecordsOnly}: HandleIsolatedEntityArgs) => boolean | Model[]; + handleEntityRecords: ({findMatchingRecordBy, fieldName, operator, rawValues, tableName, prepareRecordsOnly}: HandleEntityRecordsArgs) => Promise; + processInputs: ({rawValues, tableName, findMatchingRecordBy, fieldName}: ProcessInputsArgs) => Promise<{ createRaws: RecordPair[]; updateRaws: RecordPair[] }>; + batchOperations: ({database, models}: BatchOperationsArgs) => Promise; + prepareRecords: ({database, tableName, createRaws, updateRaws, recordOperator}: PrepareRecordsArgs) => Promise; + executeInDatabase: ({createRaws, recordOperator, tableName, updateRaws}: PrepareForDatabaseArgs) => Promise; + getDatabase: (tableName: string) => Database; + getDefaultDatabase: () => Promise; + getServerDatabase: () => Promise; +} + +class BaseHandler { + /** + * activeDatabase : In a multi-server configuration, this connection will be used by WebSockets and other parties to update databases other than the active one. + * @type {DatabaseInstance} + */ + activeDatabase: DatabaseInstance; + + constructor(serverDatabase?: Database) { + this.activeDatabase = serverDatabase; + } + + /** + * getActiveDatabase : getter for the activeDatabase + * @returns {DatabaseInstance} + */ + getActiveDatabase = () => this.activeDatabase; + + /** + * setActiveDatabase: setter for the activeDatabase + * @param {} database + */ + setActiveDatabase = (database: Database) => { + this.activeDatabase = database; + }; + + /** + * handleIsolatedEntity: Handler responsible for the Create/Update operations on the isolated entities as described + * by the IsolatedEntities enum + * @param {HandleIsolatedEntityArgs} isolatedEntityArgs + * @param {IsolatedEntities} isolatedEntityArgs.tableName + * @param {boolean} isolatedEntityArgs.prepareRecordsOnly + * @param {RawValue} isolatedEntityArgs.values + * @throws DataOperatorException + * @returns {Model[] | boolean} + */ + handleIsolatedEntity = async ({tableName, values, prepareRecordsOnly = true}: HandleIsolatedEntityArgs) => { + let findMatchingRecordBy; + let fieldName; + let operator; + let rawValues; + + if (!values.length) { + throw new DataOperatorException( + `An empty "values" array has been passed to the handleIsolatedEntity method for entity ${tableName}`, + ); + } + + switch (tableName) { + case IsolatedEntities.APP: { + findMatchingRecordBy = isRecordAppEqualToRaw; + fieldName = 'version_number'; + operator = prepareAppRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'version_number'}); + break; + } + case IsolatedEntities.CUSTOM_EMOJI: { + findMatchingRecordBy = isRecordCustomEmojiEqualToRaw; + fieldName = 'id'; + operator = prepareCustomEmojiRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'id'}); + break; + } + case IsolatedEntities.GLOBAL: { + findMatchingRecordBy = isRecordGlobalEqualToRaw; + fieldName = 'name'; + operator = prepareGlobalRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'name'}); + break; + } + case IsolatedEntities.ROLE: { + findMatchingRecordBy = isRecordRoleEqualToRaw; + fieldName = 'id'; + operator = prepareRoleRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'id'}); + break; + } + case IsolatedEntities.SERVERS: { + findMatchingRecordBy = isRecordServerEqualToRaw; + fieldName = 'url'; + operator = prepareServersRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'display_name'}); + break; + } + case IsolatedEntities.SYSTEM: { + findMatchingRecordBy = isRecordSystemEqualToRaw; + fieldName = 'id'; + operator = prepareSystemRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'id'}); + break; + } + case IsolatedEntities.TERMS_OF_SERVICE: { + findMatchingRecordBy = isRecordTermsOfServiceEqualToRaw; + fieldName = 'id'; + operator = prepareTermsOfServiceRecord; + rawValues = getUniqueRawsBy({raws: values, key: 'id'}); + break; + } + default: { + throw new DataOperatorException( + `handleIsolatedEntity was called with an invalid table name ${tableName}`, + ); + } + } + + if (fieldName && findMatchingRecordBy) { + const records = await this.handleEntityRecords({ + fieldName, + findMatchingRecordBy, + operator, + prepareRecordsOnly, + rawValues, + tableName, + }); + + return prepareRecordsOnly && records?.length && records; + } + + return false; + }; + + /** + * handleEntityRecords : Utility that processes some entities' data against values already present in the database so as to avoid duplicity. + * @param {HandleEntityRecordsArgs} handleEntityArgs + * @param {(existing: Model, newElement: RawValue) => boolean} handleEntityArgs.findMatchingRecordBy + * @param {string} handleEntityArgs.fieldName + * @param {(DataFactoryArgs) => Promise} handleEntityArgs.operator + * @param {RawValue[]} handleEntityArgs.rawValues + * @param {string} handleEntityArgs.tableName + * @returns {Promise} + */ + handleEntityRecords = async ({findMatchingRecordBy, fieldName, operator, rawValues, tableName, prepareRecordsOnly = true}: HandleEntityRecordsArgs) => { + if (!rawValues.length) { + return null; + } + + const {createRaws, updateRaws} = await this.processInputs({ + rawValues, + tableName, + findMatchingRecordBy, + fieldName, + }); + + const database = await this.getDatabase(tableName); + + const models = await this.prepareRecords({ + database, + tableName, + createRaws, + updateRaws, + recordOperator: operator, + }); + + if (prepareRecordsOnly) { + return models; + } + + if (models?.length > 0) { + await this.batchOperations({database, models}); + } + + return null; + }; + + /** + * processInputs: This method weeds out duplicates entries. It may happen that we do multiple inserts for + * the same value. Hence, prior to that we query the database and pick only those values that are 'new' from the 'Raw' array. + * @param {ProcessInputsArgs} inputsArg + * @param {RawValue[]} inputsArg.rawValues + * @param {string} inputsArg.tableName + * @param {string} inputsArg.fieldName + * @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.findMatchingRecordBy + * @returns {Promise<{createRaws: RecordPair[], updateRaws: RecordPair[]} | {createRaws: RecordPair[], updateRaws: RecordPair[]}>} + */ + processInputs = async ({rawValues, tableName, findMatchingRecordBy, fieldName}: ProcessInputsArgs) => { + // We will query an entity where one of its fields can match a range of values. Hence, here we are extracting all those potential values. + const columnValues: string[] = getRangeOfValues({ + fieldName, + raws: rawValues, + }); + + const database = await this.getDatabase(tableName); + + const existingRecords = await retrieveRecords({ + database, + tableName, + condition: Q.where(fieldName, Q.oneOf(columnValues)), + }); + + const createRaws: RecordPair[] = []; + const updateRaws: RecordPair[] = []; + + if (existingRecords.length > 0) { + rawValues.forEach((newElement: RawValue) => { + const findIndex = existingRecords.findIndex((existing) => { + return findMatchingRecordBy(existing, newElement); + }); + + // We found a record in the database that matches this element; hence, we'll proceed for an UPDATE operation + if (findIndex > -1) { + const existingRecord = existingRecords[findIndex]; + + // Some raw value has an update_at field. We'll proceed to update only if the update_at value is different from the record's value in database + const isUpdateAtSimilar = hasSimilarUpdateAt({ + tableName, + existingRecord, + newValue: newElement, + }); + + if (!isUpdateAtSimilar) { + return updateRaws.push({ + record: existingRecord, + raw: newElement, + }); + } + } else { + // This RawValue is not present in the database; hence, we need to create it + return createRaws.push({record: undefined, raw: newElement}); + } + return null; + }); + + return { + createRaws, + updateRaws, + }; + } + + return { + createRaws: getRawRecordPairs(rawValues), + updateRaws, + }; + }; + + /** + * batchOperations: Accepts an instance of Database (either Default or Server) and an array of + * prepareCreate/prepareUpdate 'models' and executes the actions on the database. + * @param {BatchOperationsArgs} operation + * @param {Database} operation.database + * @param {Array} operation.models + * @throws {DataOperatorException} + * @returns {Promise} + */ + batchOperations = async ({database, models}: BatchOperationsArgs) => { + try { + if (models.length > 0) { + await database.action(async () => { + await database.batch(...models); + }); + } + } catch (e) { + throw new DataOperatorException('batchOperations error ', e); + } + }; + + /** + * prepareRecords: Utility method that actually calls the operators for the handlers + * @param {PrepareRecordsArgs} prepareRecord + * @param {Database} prepareRecord.database + * @param {string} prepareRecord.tableName + * @param {RawValue[]} prepareRecord.createRaws + * @param {RawValue[]} prepareRecord.updateRaws + * @param {(DataFactoryArgs) => Promise;} prepareRecord.recordOperator + * @throws {DataOperatorException} + * @returns {Promise} + */ + prepareRecords = async ({database, tableName, createRaws, updateRaws, recordOperator}: PrepareRecordsArgs) => { + if (!database) { + throw new DataOperatorException( + 'prepareRecords accepts only rawPosts of type RawValue[] or valid database connection', + ); + } + + let preparedRecords: Promise[] = []; + + // create operation + if (createRaws?.length) { + const recordPromises = createRaws.map( + (createRecord: RecordPair) => { + return recordOperator({ + database, + tableName, + value: createRecord, + action: OperationType.CREATE, + }); + }, + ); + + preparedRecords = preparedRecords.concat(recordPromises); + } + + // update operation + if (updateRaws?.length) { + const recordPromises = updateRaws.map( + (updateRecord: RecordPair) => { + return recordOperator({ + database, + tableName, + value: updateRecord, + action: OperationType.UPDATE, + }); + }, + ); + + preparedRecords = preparedRecords.concat(recordPromises); + } + + const results = await Promise.all(preparedRecords); + return results; + }; + + /** + * executeInDatabase: Handles the Create/Update operations on an entity. + * @param {PrepareForDatabaseArgs} executeInDatabase + * @param {string} executeInDatabase.tableName + * @param {RecordValue[]} executeInDatabase.createRaws + * @param {RecordValue[]} executeInDatabase.updateRaws + * @param {(DataFactoryArgs) => Promise} executeInDatabase.recordOperator + * @returns {Promise} + */ + executeInDatabase = async ({createRaws, recordOperator, tableName, updateRaws}: PrepareForDatabaseArgs) => { + const database = await this.getDatabase(tableName); + + const models = await this.prepareRecords({ + database, + tableName, + createRaws, + updateRaws, + recordOperator, + }); + + if (models?.length > 0) { + await this.batchOperations({database, models}); + } + }; + + /** + * getDatabase: Based on the table's name, it will return a database instance either from the 'DEFAULT' database or + * the 'SERVER' database + * @param {string} tableName + * @returns {Promise} + */ + getDatabase = async (tableName: string) => { + const isDefaultConnection = Object.values(MM_TABLES.DEFAULT).some((tbName) => { + return tableName === tbName; + }); + + const promise = isDefaultConnection ? this.getDefaultDatabase : this.getServerDatabase; + const connection = await promise(); + + return connection; + }; + + /** + * getDefaultDatabase: Returns the default database + * @throws {DatabaseConnectionException} + * @returns {Promise} + */ + getDefaultDatabase = async () => { + const connection = await DatabaseManager.getDefaultDatabase(); + if (connection === undefined) { + throw new DatabaseConnectionException( + 'An error occurred while retrieving the default database', + '', + ); + } + return connection; + }; + + /** + * getServerDatabase: Returns the current active server database (multi-server support) + * @throws {DatabaseConnectionException} + * @returns {Promise} + */ + getServerDatabase = async () => { + // Third parties trying to update the database + if (this.activeDatabase) { + return this.activeDatabase; + } + + // NOTE: here we are getting the active server directly as in a multi-server support system, the current + // active server connection will already be set on application init + const connection = await DatabaseManager.getActiveServerDatabase(); + if (connection === undefined) { + throw new DatabaseConnectionException( + 'An error occurred while retrieving the server database', + '', + ); + } + return connection; + }; +} + +export default BaseHandler; diff --git a/app/database/operator/handlers/channel.test.ts b/app/database/operator/handlers/channel.test.ts new file mode 100644 index 0000000000..961de26a85 --- /dev/null +++ b/app/database/operator/handlers/channel.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DataOperator} from '@database/operator'; +import { + isRecordChannelEqualToRaw, + isRecordChannelInfoEqualToRaw, + isRecordMyChannelEqualToRaw, + isRecordMyChannelSettingsEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareChannelInfoRecord, + prepareChannelRecord, + prepareMyChannelRecord, + prepareMyChannelSettingsRecord, +} from '@database/operator/prepareRecords/channel'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; + +jest.mock('@database/manager'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** Operator: Channel Handlers tests ***', () => { + it('=> HandleChannel: should write to CHANNEL entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'channel_handler', setActive: true}); + + await DataOperator.handleChannel({ + channels: [ + { + id: 'kjlw9j1ttnxwig7tnqgebg7dtipno', + create_at: 1600185541285, + update_at: 1604401077256, + delete_at: 0, + team_id: '', + type: 'D', + display_name: '', + name: 'gh781zkzkhh357b4bejephjz5u8daw__9ciscaqbrpd6d8s68k76xb9bte', + header: '(https://mattermost', + purpose: '', + last_post_at: 1617311494451, + total_msg_count: 585, + extra_update_at: 0, + creator_id: '', + scheme_id: null, + props: null, + group_constrained: null, + shared: null, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'id', + rawValues: [ + { + id: 'kjlw9j1ttnxwig7tnqgebg7dtipno', + create_at: 1600185541285, + update_at: 1604401077256, + delete_at: 0, + team_id: '', + type: 'D', + display_name: '', + name: 'gh781zkzkhh357b4bejephjz5u8daw__9ciscaqbrpd6d8s68k76xb9bte', + header: '(https://mattermost', + purpose: '', + last_post_at: 1617311494451, + total_msg_count: 585, + extra_update_at: 0, + creator_id: '', + scheme_id: null, + props: null, + group_constrained: null, + shared: null, + }, + ], + tableName: 'Channel', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordChannelEqualToRaw, + operator: prepareChannelRecord, + }); + }); + + it('=> HandleMyChannelSettings: should write to MY_CHANNEL_SETTINGS entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'channel_handler', setActive: true}); + + await DataOperator.handleMyChannelSettings({ + settings: [ + { + channel_id: 'c', + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + }, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'channel_id', + rawValues: [ + { + channel_id: 'c', + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + }, + }, + ], + tableName: 'MyChannelSettings', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordMyChannelSettingsEqualToRaw, + operator: prepareMyChannelSettingsRecord, + }); + }); + + it('=> HandleChannelInfo: should write to CHANNEL_INFO entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'channel_handler', setActive: true}); + + await DataOperator.handleChannelInfo({ + channelInfos: [ + { + channel_id: 'c', + guest_count: 10, + header: 'channel info header', + member_count: 10, + pinned_post_count: 3, + purpose: 'sample channel ', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'channel_id', + rawValues: [ + { + channel_id: 'c', + guest_count: 10, + header: 'channel info header', + member_count: 10, + pinned_post_count: 3, + purpose: 'sample channel ', + }, + ], + tableName: 'ChannelInfo', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordChannelInfoEqualToRaw, + operator: prepareChannelInfoRecord, + }); + }); + + it('=> HandleMyChannel: should write to MY_CHANNEL entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'channel_handler', setActive: true}); + + await DataOperator.handleMyChannel({ + myChannels: [ + { + channel_id: 'c', + last_post_at: 1617311494451, + last_viewed_at: 1617311494451, + mentions_count: 3, + message_count: 10, + roles: 'guest', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'channel_id', + rawValues: [ + { + channel_id: 'c', + last_post_at: 1617311494451, + last_viewed_at: 1617311494451, + mentions_count: 3, + message_count: 10, + roles: 'guest', + }, + ], + tableName: 'MyChannel', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordMyChannelEqualToRaw, + operator: prepareMyChannelRecord, + }); + }); +}); diff --git a/app/database/operator/handlers/channel.ts b/app/database/operator/handlers/channel.ts new file mode 100644 index 0000000000..00e60a2bd1 --- /dev/null +++ b/app/database/operator/handlers/channel.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import { + isRecordChannelEqualToRaw, + isRecordChannelInfoEqualToRaw, + isRecordMyChannelEqualToRaw, + isRecordMyChannelSettingsEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareChannelInfoRecord, + prepareChannelRecord, + prepareMyChannelRecord, + prepareMyChannelSettingsRecord, +} from '@database/operator/prepareRecords/channel'; +import {getUniqueRawsBy} from '@database/operator/utils/general'; +import Channel from '@typings/database/channel'; +import ChannelInfo from '@typings/database/channel_info'; +import { + HandleChannelArgs, + HandleChannelInfoArgs, + HandleMyChannelArgs, + HandleMyChannelSettingsArgs, +} from '@typings/database/database'; +import MyChannel from '@typings/database/my_channel'; +import MyChannelSettings from '@typings/database/my_channel_settings'; + +const { + CHANNEL, + CHANNEL_INFO, + MY_CHANNEL, + MY_CHANNEL_SETTINGS, +} = MM_TABLES.SERVER; + +export interface ChannelHandlerMix { + handleChannel: ({channels, prepareRecordsOnly}: HandleChannelArgs) => Channel[] | boolean; + handleMyChannelSettings: ({settings, prepareRecordsOnly}: HandleMyChannelSettingsArgs) => MyChannelSettings[] | boolean; + handleChannelInfo: ({channelInfos, prepareRecordsOnly}: HandleChannelInfoArgs) => ChannelInfo[] | boolean; + handleMyChannel: ({myChannels, prepareRecordsOnly}: HandleMyChannelArgs) => MyChannel[] | boolean; +} + +const ChannelHandler = (superclass: any) => class extends superclass { + /** + * handleChannel: Handler responsible for the Create/Update operations occurring on the CHANNEL entity from the 'Server' schema + * @param {HandleChannelArgs} channelsArgs + * @param {RawChannel[]} channelsArgs.channels + * @param {boolean} channelsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Channel[]|boolean} + */ + handleChannel = async ({channels, prepareRecordsOnly = true}: HandleChannelArgs) => { + if (!channels.length) { + throw new DataOperatorException( + 'An empty "channels" array has been passed to the handleChannel method', + ); + } + + const rawValues = getUniqueRawsBy({raws: channels, key: 'id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordChannelEqualToRaw, + operator: prepareChannelRecord, + prepareRecordsOnly, + rawValues, + tableName: CHANNEL, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleMyChannelSettings: Handler responsible for the Create/Update operations occurring on the MY_CHANNEL_SETTINGS entity from the 'Server' schema + * @param {HandleMyChannelSettingsArgs} settingsArgs + * @param {RawMyChannelSettings[]} settingsArgs.settings + * @param {boolean} settingsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {MyChannelSettings[]| boolean} + */ + handleMyChannelSettings = async ({settings, prepareRecordsOnly = true}: HandleMyChannelSettingsArgs) => { + if (!settings.length) { + throw new DataOperatorException( + 'An empty "settings" array has been passed to the handleMyChannelSettings method', + ); + } + + const rawValues = getUniqueRawsBy({raws: settings, key: 'channel_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'channel_id', + findMatchingRecordBy: isRecordMyChannelSettingsEqualToRaw, + operator: prepareMyChannelSettingsRecord, + prepareRecordsOnly, + rawValues, + tableName: MY_CHANNEL_SETTINGS, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleChannelInfo: Handler responsible for the Create/Update operations occurring on the CHANNEL_INFO entity from the 'Server' schema + * @param {HandleChannelInfoArgs} channelInfosArgs + * @param {RawChannelInfo[]} channelInfosArgs.channelInfos + * @param {boolean} channelInfosArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {ChannelInfo[]| boolean} + */ + handleChannelInfo = async ({channelInfos, prepareRecordsOnly = true}: HandleChannelInfoArgs) => { + if (!channelInfos.length) { + throw new DataOperatorException( + 'An empty "channelInfos" array has been passed to the handleMyChannelSettings method', + ); + } + + const rawValues = getUniqueRawsBy({ + raws: channelInfos, + key: 'channel_id', + }); + + const records = await this.handleEntityRecords({ + fieldName: 'channel_id', + findMatchingRecordBy: isRecordChannelInfoEqualToRaw, + operator: prepareChannelInfoRecord, + prepareRecordsOnly, + rawValues, + tableName: CHANNEL_INFO, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleMyChannel: Handler responsible for the Create/Update operations occurring on the MY_CHANNEL entity from the 'Server' schema + * @param {HandleMyChannelArgs} myChannelsArgs + * @param {RawMyChannel[]} myChannelsArgs.myChannels + * @param {boolean} myChannelsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {MyChannel[]| boolean} + */ + handleMyChannel = async ({myChannels, prepareRecordsOnly = true}: HandleMyChannelArgs) => { + if (!myChannels.length) { + throw new DataOperatorException( + 'An empty "myChannels" array has been passed to the handleMyChannel method', + ); + } + + const rawValues = getUniqueRawsBy({ + raws: myChannels, + key: 'channel_id', + }); + + const records = await this.handleEntityRecords({ + fieldName: 'channel_id', + findMatchingRecordBy: isRecordMyChannelEqualToRaw, + operator: prepareMyChannelRecord, + prepareRecordsOnly, + rawValues, + tableName: MY_CHANNEL, + }); + + return prepareRecordsOnly && records?.length && records; + }; +}; + +export default ChannelHandler; diff --git a/app/database/operator/handlers/group.test.ts b/app/database/operator/handlers/group.test.ts new file mode 100644 index 0000000000..531b470f21 --- /dev/null +++ b/app/database/operator/handlers/group.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DataOperator} from '@database/operator'; +import { + isRecordGroupEqualToRaw, + isRecordGroupMembershipEqualToRaw, + isRecordGroupsInChannelEqualToRaw, + isRecordGroupsInTeamEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareGroupMembershipRecord, + prepareGroupRecord, + prepareGroupsInChannelRecord, + prepareGroupsInTeamRecord, +} from '@database/operator/prepareRecords/group'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; + +jest.mock('@database/manager'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** Operator: Group Handlers tests ***', () => { + it('=> HandleGroup: should write to GROUP entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'group_handler', setActive: true}); + + await DataOperator.handleGroup({ + groups: [ + { + id: 'id_groupdfjdlfkjdkfdsf', + name: 'mobile_team', + display_name: 'mobile team', + description: '', + source: '', + remote_id: '', + create_at: 0, + update_at: 0, + delete_at: 0, + has_syncables: true, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'name', + rawValues: [ + { + id: 'id_groupdfjdlfkjdkfdsf', + name: 'mobile_team', + display_name: 'mobile team', + description: '', + source: '', + remote_id: '', + create_at: 0, + update_at: 0, + delete_at: 0, + has_syncables: true, + }, + ], + tableName: 'Group', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordGroupEqualToRaw, + operator: prepareGroupRecord, + }); + }); + + it('=> HandleGroupsInTeam: should write to GROUPS_IN_TEAM entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'group_handler', setActive: true}); + + await DataOperator.handleGroupsInTeam({ + groupsInTeams: [ + { + team_id: 'team_899', + team_display_name: '', + team_type: '', + group_id: 'group_id89', + auto_add: true, + create_at: 0, + delete_at: 0, + update_at: 0, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'group_id', + rawValues: [ + { + team_id: 'team_899', + team_display_name: '', + team_type: '', + group_id: 'group_id89', + auto_add: true, + create_at: 0, + delete_at: 0, + update_at: 0, + }, + ], + tableName: 'GroupsInTeam', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordGroupsInTeamEqualToRaw, + operator: prepareGroupsInTeamRecord, + }); + }); + + it('=> HandleGroupsInChannel: should write to GROUPS_IN_CHANNEL entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'group_handler', setActive: true}); + + await DataOperator.handleGroupsInChannel({ + groupsInChannels: [ + { + auto_add: true, + channel_display_name: '', + channel_id: 'channelid', + channel_type: '', + create_at: 0, + delete_at: 0, + group_id: 'groupId', + team_display_name: '', + team_id: '', + team_type: '', + update_at: 0, + member_count: 0, + timezone_count: 0, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'group_id', + rawValues: [ + { + auto_add: true, + channel_display_name: '', + channel_id: 'channelid', + channel_type: '', + create_at: 0, + delete_at: 0, + group_id: 'groupId', + team_display_name: '', + team_id: '', + team_type: '', + update_at: 0, + member_count: 0, + timezone_count: 0, + }, + ], + tableName: 'GroupsInChannel', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordGroupsInChannelEqualToRaw, + operator: prepareGroupsInChannelRecord, + }); + }); + + it('=> HandleGroupMembership: should write to GROUP_MEMBERSHIP entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'group_handler', setActive: true}); + + await DataOperator.handleGroupMembership({ + groupMemberships: [ + { + user_id: 'u4cprpki7ri81mbx8efixcsb8jo', + group_id: 'g4cprpki7ri81mbx8efixcsb8jo', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'user_id', + rawValues: [ + { + user_id: 'u4cprpki7ri81mbx8efixcsb8jo', + group_id: 'g4cprpki7ri81mbx8efixcsb8jo', + }, + ], + tableName: 'GroupMembership', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordGroupMembershipEqualToRaw, + operator: prepareGroupMembershipRecord, + }); + }); +}); diff --git a/app/database/operator/handlers/group.ts b/app/database/operator/handlers/group.ts new file mode 100644 index 0000000000..d2957ca346 --- /dev/null +++ b/app/database/operator/handlers/group.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import { + isRecordGroupEqualToRaw, + isRecordGroupMembershipEqualToRaw, + isRecordGroupsInChannelEqualToRaw, + isRecordGroupsInTeamEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareGroupMembershipRecord, + prepareGroupRecord, + prepareGroupsInChannelRecord, + prepareGroupsInTeamRecord, +} from '@database/operator/prepareRecords/group'; +import {getUniqueRawsBy} from '@database/operator/utils/general'; +import { + HandleGroupArgs, + HandleGroupMembershipArgs, + HandleGroupsInChannelArgs, + HandleGroupsInTeamArgs, +} from '@typings/database/database'; +import Group from '@typings/database/group'; +import GroupMembership from '@typings/database/group_membership'; +import GroupsInChannel from '@typings/database/groups_in_channel'; +import GroupsInTeam from '@typings/database/groups_in_team'; + +const { + GROUP, + GROUPS_IN_CHANNEL, + GROUPS_IN_TEAM, + GROUP_MEMBERSHIP, +} = MM_TABLES.SERVER; + +export interface GroupHandlerMix { + handleGroupMembership : ({groupMemberships, prepareRecordsOnly}: HandleGroupMembershipArgs) => GroupMembership[] | boolean, + handleGroup : ({groups, prepareRecordsOnly}: HandleGroupArgs) => Group[] | boolean, + handleGroupsInTeam : ({groupsInTeams, prepareRecordsOnly} : HandleGroupsInTeamArgs) => GroupsInTeam[] | boolean, + handleGroupsInChannel : ({groupsInChannels, prepareRecordsOnly}: HandleGroupsInChannelArgs) => GroupsInChannel[] | boolean +} + +const GroupHandler = (superclass: any) => class extends superclass { + /** + * handleGroupMembership: Handler responsible for the Create/Update operations occurring on the GROUP_MEMBERSHIP entity from the 'Server' schema + * @param {HandleGroupMembershipArgs} groupMembershipsArgs + * @param {RawGroupMembership[]} groupMembershipsArgs.groupMemberships + * @param {boolean} groupMembershipsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {GroupMembership[] | boolean} + */ + handleGroupMembership = async ({groupMemberships, prepareRecordsOnly = true}: HandleGroupMembershipArgs) => { + if (!groupMemberships.length) { + throw new DataOperatorException( + 'An empty "groupMemberships" array has been passed to the handleGroupMembership method', + ); + } + + const rawValues = getUniqueRawsBy({raws: groupMemberships, key: 'group_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'user_id', + findMatchingRecordBy: isRecordGroupMembershipEqualToRaw, + operator: prepareGroupMembershipRecord, + prepareRecordsOnly, + rawValues, + tableName: GROUP_MEMBERSHIP, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleGroup: Handler responsible for the Create/Update operations occurring on the GROUP entity from the 'Server' schema + * @param {HandleGroupArgs} groupsArgs + * @param {RawGroup[]} groupsArgs.groups + * @param {boolean} groupsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Group[] | boolean} + */ + handleGroup = async ({groups, prepareRecordsOnly = true}: HandleGroupArgs) => { + if (!groups.length) { + throw new DataOperatorException( + 'An empty "groups" array has been passed to the handleGroup method', + ); + } + + const rawValues = getUniqueRawsBy({raws: groups, key: 'name'}); + + const records = await this.handleEntityRecords({ + fieldName: 'name', + findMatchingRecordBy: isRecordGroupEqualToRaw, + operator: prepareGroupRecord, + prepareRecordsOnly, + rawValues, + tableName: GROUP, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleGroupsInTeam: Handler responsible for the Create/Update operations occurring on the GROUPS_IN_TEAM entity from the 'Server' schema + * @param {HandleGroupsInTeamArgs} groupsInTeamsArgs + * @param {RawGroupsInTeam[]} groupsInTeamsArgs.groupsInTeams + * @param {boolean} groupsInTeamsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {GroupsInTeam[] | boolean} + */ + handleGroupsInTeam = async ({groupsInTeams, prepareRecordsOnly = true} : HandleGroupsInTeamArgs) => { + if (!groupsInTeams.length) { + throw new DataOperatorException( + 'An empty "groups" array has been passed to the handleGroupsInTeam method', + ); + } + + const rawValues = getUniqueRawsBy({raws: groupsInTeams, key: 'group_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'group_id', + findMatchingRecordBy: isRecordGroupsInTeamEqualToRaw, + operator: prepareGroupsInTeamRecord, + prepareRecordsOnly, + rawValues, + tableName: GROUPS_IN_TEAM, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleGroupsInChannel: Handler responsible for the Create/Update operations occurring on the GROUPS_IN_CHANNEL entity from the 'Server' schema + * @param {HandleGroupsInChannelArgs} groupsInChannelsArgs + * @param {RawGroupsInChannel[]} groupsInChannelsArgs.groupsInChannels + * @param {boolean} groupsInChannelsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {GroupsInChannel[] | boolean} + */ + handleGroupsInChannel = async ({groupsInChannels, prepareRecordsOnly = true}: HandleGroupsInChannelArgs) => { + if (!groupsInChannels.length) { + throw new DataOperatorException( + 'An empty "groups" array has been passed to the handleGroupsInTeam method', + ); + } + + const rawValues = getUniqueRawsBy({raws: groupsInChannels, key: 'channel_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'group_id', + findMatchingRecordBy: isRecordGroupsInChannelEqualToRaw, + operator: prepareGroupsInChannelRecord, + prepareRecordsOnly, + rawValues, + tableName: GROUPS_IN_CHANNEL, + }); + + return prepareRecordsOnly && records?.length && records; + }; +}; + +export default GroupHandler; diff --git a/app/database/operator/handlers/post.test.ts b/app/database/operator/handlers/post.test.ts new file mode 100644 index 0000000000..9473006c6a --- /dev/null +++ b/app/database/operator/handlers/post.test.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DataOperator} from '@database/operator'; +import {isRecordDraftEqualToRaw} from '@database/operator/comparators'; +import {prepareDraftRecord} from '@database/operator/prepareRecords/post'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; + +jest.mock('@database/manager'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** Operator: Post Handlers tests ***', () => { + it('=> HandleDraft: should write to the Draft entity', async () => { + expect.assertions(1); + + await createTestConnection({databaseName: 'post_handler', setActive: true}); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values = [ + { + channel_id: '4r9jmr7eqt8dxq3f9woypzurrychannelid', + files: [ + { + user_id: 'user_id', + post_id: 'post_id', + create_at: 123, + update_at: 456, + delete_at: 789, + name: 'an_image', + extension: 'jpg', + size: 10, + mime_type: 'image', + width: 10, + height: 10, + has_preview_image: false, + clientId: 'clientId', + }, + ], + message: 'test draft message for post', + root_id: '', + }, + ]; + + await DataOperator.handleDraft({drafts: values, prepareRecordsOnly: false}); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + findMatchingRecordBy: isRecordDraftEqualToRaw, + fieldName: 'channel_id', + operator: prepareDraftRecord, + rawValues: values, + tableName: 'Draft', + prepareRecordsOnly: false, + }); + }); + + it('=> HandlePosts: should write to Post and its sub-child entities', async () => { + expect.assertions(12); + + const posts = [ + { + id: '8swgtrrdiff89jnsiwiip3y1eoe', + create_at: 1596032651747, + update_at: 1596032651747, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '', + parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', + original_id: '', + message: "I'll second these kudos! Thanks m!", + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: { + images: { + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { + width: 400, + height: 400, + format: 'png', + frame_count: 0, + }, + }, + reactions: [ + { + user_id: 'njic1w1k5inefp848jwk6oukio', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + emoji_name: 'clap', + create_at: 1608252965442, + update_at: 1608252965442, + delete_at: 0, + }, + ], + embeds: [ + { + type: 'opengraph', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + data: { + type: 'object', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + title: 'mickmister/mattermost-plugin-default-theme', + description: 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', + determiner: '', + site_name: 'GitHub', + locale: '', + locales_alternate: null, + images: [ + { + url: '', + secure_url: 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', + type: '', + width: 0, + height: 0, + }, + ], + audios: null, + videos: null, + }, + }, + ], + emojis: [ + { + id: 'dgwyadacdbbwjc8t357h6hwsrh', + create_at: 1502389307432, + update_at: 1502389307432, + delete_at: 0, + creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', + name: 'thanks', + }, + ], + files: [ + { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ], + }, + }, + { + id: '8fcnk3p1jt8mmkaprgajoxz115a', + create_at: 1596104683748, + update_at: 1596104683748, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '8swgtrrdiff89jnsiwiip3y1eoe', + parent_id: '', + original_id: '', + message: 'a added to the channel by j.', + type: 'system_add_to_channel', + props: { + addedUserId: 'z89qsntet7bimd3xddfu7u9ncdaxc', + addedUsername: 'a', + userId: 'hy5sdfdfq51sebfh58ktrce5ijtcwy', + username: 'j', + }, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + { + id: '3y3w3a6gkbg73bnj3xund9o5ic', + create_at: 1596277483749, + update_at: 1596277483749, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: '44ud4m9tqwby3mphzzdwm7h31sr', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '8swgtrrdiff89jnsiwiip3y1eoe', + parent_id: 'ps81iqbwesfby8jayz7owg4yypo', + original_id: '', + message: 'Great work M!', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + ]; + + const spyOnHandleFiles = jest.spyOn(DataOperator as any, 'handleFiles'); + const spyOnHandlePostMetadata = jest.spyOn(DataOperator as any, 'handlePostMetadata'); + const spyOnHandleReactions = jest.spyOn(DataOperator as any, 'handleReactions'); + const spyOnHandleCustomEmojis = jest.spyOn(DataOperator as any, 'handleIsolatedEntity'); + const spyOnHandlePostsInThread = jest.spyOn(DataOperator as any, 'handlePostsInThread'); + const spyOnHandlePostsInChannel = jest.spyOn(DataOperator as any, 'handlePostsInChannel'); + + await createTestConnection({databaseName: 'post_handler', setActive: true}); + + // handlePosts will in turn call handlePostsInThread + await DataOperator.handlePosts({ + orders: [ + '8swgtrrdiff89jnsiwiip3y1eoe', + '8fcnk3p1jt8mmkaprgajoxz115a', + '3y3w3a6gkbg73bnj3xund9o5ic', + ], + values: posts, + previousPostId: '', + }); + + expect(spyOnHandleReactions).toHaveBeenCalledTimes(1); + expect(spyOnHandleReactions).toHaveBeenCalledWith({ + reactions: [ + { + user_id: 'njic1w1k5inefp848jwk6oukio', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + emoji_name: 'clap', + create_at: 1608252965442, + update_at: 1608252965442, + delete_at: 0, + }, + ], + prepareRecordsOnly: true, + }); + + expect(spyOnHandleFiles).toHaveBeenCalledTimes(1); + expect(spyOnHandleFiles).toHaveBeenCalledWith({ + files: [ + { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ], + prepareRecordsOnly: true, + }); + + expect(spyOnHandlePostMetadata).toHaveBeenCalledTimes(1); + expect(spyOnHandlePostMetadata).toHaveBeenCalledWith({ + embeds: [ + { + embed: [ + { + type: 'opengraph', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + data: { + type: 'object', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + title: 'mickmister/mattermost-plugin-default-theme', + description: 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', + determiner: '', + site_name: 'GitHub', + locale: '', + locales_alternate: null, + images: [ + { + url: '', + secure_url: 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', + type: '', + width: 0, + height: 0, + }, + ], + audios: null, + videos: null, + }, + }, + ], + postId: '8swgtrrdiff89jnsiwiip3y1eoe', + }, + ], + images: [ + { + images: { + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { + width: 400, + height: 400, + format: 'png', + frame_count: 0, + }, + }, + postId: '8swgtrrdiff89jnsiwiip3y1eoe', + }, + ], + prepareRecordsOnly: true, + }); + + expect(spyOnHandleCustomEmojis).toHaveBeenCalledTimes(1); + expect(spyOnHandleCustomEmojis).toHaveBeenCalledWith({ + tableName: 'CustomEmoji', + prepareRecordsOnly: false, + values: [ + { + id: 'dgwyadacdbbwjc8t357h6hwsrh', + create_at: 1502389307432, + update_at: 1502389307432, + delete_at: 0, + creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', + name: 'thanks', + }, + ], + }); + + expect(spyOnHandlePostsInThread).toHaveBeenCalledTimes(1); + expect(spyOnHandlePostsInThread).toHaveBeenCalledWith([ + {earliest: 1596032651747, post_id: '8swgtrrdiff89jnsiwiip3y1eoe'}, + ]); + + expect(spyOnHandlePostsInChannel).toHaveBeenCalledTimes(1); + expect(spyOnHandlePostsInChannel).toHaveBeenCalledWith(posts.slice(0, 3)); + }); +}); diff --git a/app/database/operator/handlers/post.ts b/app/database/operator/handlers/post.ts new file mode 100644 index 0000000000..37c2f76dd6 --- /dev/null +++ b/app/database/operator/handlers/post.ts @@ -0,0 +1,491 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import {isRecordDraftEqualToRaw, isRecordPostEqualToRaw} from '@database/operator/comparators'; +import { + prepareDraftRecord, + prepareFileRecord, + preparePostInThreadRecord, + preparePostMetadataRecord, + preparePostRecord, + preparePostsInChannelRecord, +} from '@database/operator/prepareRecords/post'; +import {getRawRecordPairs, getUniqueRawsBy, retrieveRecords} from '@database/operator/utils/general'; +import {createPostsChain, sanitizePosts} from '@database/operator/utils/post'; +import {Q} from '@nozbe/watermelondb'; +import Model from '@nozbe/watermelondb/Model'; +import { + HandleDraftArgs, + HandleFilesArgs, + HandlePostMetadataArgs, + HandlePostsArgs, + PostImage, + RawCustomEmoji, + RawEmbed, + RawFile, + RawPost, + RawPostMetadata, + RawPostsInThread, + RawReaction, RecordPair, +} from '@typings/database/database'; +import Draft from '@typings/database/draft'; +import {IsolatedEntities} from '@typings/database/enums'; +import File from '@typings/database/file'; +import Post from '@typings/database/post'; +import PostMetadata from '@typings/database/post_metadata'; +import PostsInChannel from '@typings/database/posts_in_channel'; +import PostsInThread from '@typings/database/posts_in_thread'; +import Reaction from '@typings/database/reaction'; + +const { + DRAFT, + FILE, + POST, + POSTS_IN_CHANNEL, + POSTS_IN_THREAD, + POST_METADATA, +} = MM_TABLES.SERVER; + +export interface PostHandlerMix { + handleDraft: ({drafts, prepareRecordsOnly}: HandleDraftArgs) => Draft[] | boolean + handleFiles: ({files, prepareRecordsOnly}: HandleFilesArgs) => Promise; + handlePostMetadata: ({embeds, images, prepareRecordsOnly}: HandlePostMetadataArgs) => Promise; + handlePosts: ({orders, values, previousPostId}: HandlePostsArgs) => Promise; + handlePostsInChannel: (posts: RawPost[]) => Promise; + handlePostsInThread: (rootPosts: RawPostsInThread[]) => Promise; +} + +const PostHandler = (superclass: any) => class extends superclass { + /** + * handleDraft: Handler responsible for the Create/Update operations occurring the Draft entity from the 'Server' schema + * @param {HandleDraftArgs} draftsArgs + * @param {RawDraft[]} draftsArgs.drafts + * @param {boolean} draftsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Draft[] | boolean} + */ + handleDraft = async ({drafts, prepareRecordsOnly = true}: HandleDraftArgs) => { + if (!drafts.length) { + throw new DataOperatorException( + 'An empty "drafts" array has been passed to the handleReactions method', + ); + } + + const rawValues = getUniqueRawsBy({raws: drafts, key: 'channel_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'channel_id', + findMatchingRecordBy: isRecordDraftEqualToRaw, + operator: prepareDraftRecord, + prepareRecordsOnly, + rawValues, + tableName: DRAFT, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handlePosts: Handler responsible for the Create/Update operations occurring on the Post entity from the 'Server' schema + * @param {HandlePostsArgs} handlePosts + * @param {string[]} handlePosts.orders + * @param {RawPost[]} handlePosts.values + * @param {string | undefined} handlePosts.previousPostId + * @returns {Promise} + */ + handlePosts = async ({orders, values, previousPostId}: HandlePostsArgs) => { + const tableName = POST; + + // We rely on the order array; if it is empty, we stop processing + if (!orders.length) { + throw new DataOperatorException( + 'An empty "order" array has been passed to the handlePosts method', + ); + } + + const rawValues = getUniqueRawsBy({ + raws: values, + key: 'id', + }) as RawPost[]; + + // By sanitizing the values, we are separating 'posts' that needs updating ( i.e. un-ordered posts ) from those that need to be created in our database + const {postsOrdered, postsUnordered} = sanitizePosts({ + posts: rawValues, + orders, + }); + + // Here we verify in our database that the postsOrdered truly need 'CREATION' + const futureEntries = await this.processInputs({ + rawValues: postsOrdered, + tableName, + findMatchingRecordBy: isRecordPostEqualToRaw, + fieldName: 'id', + }); + + if (futureEntries.createRaws?.length) { + let batch: Model[] = []; + let files: RawFile[] = []; + const postsInThread = []; + let reactions: RawReaction[] = []; + let emojis: RawCustomEmoji[] = []; + const images: { images: Dictionary; postId: string }[] = []; + const embeds: { embed: RawEmbed[]; postId: string }[] = []; + + // We create the 'chain of posts' by linking each posts' previousId to the post before it in the order array + const linkedRawPosts: RecordPair[] = createPostsChain({ + orders, + previousPostId: previousPostId || '', + rawPosts: postsOrdered, + }); + + const database = await this.getDatabase(tableName); + + // Prepares records for batch processing onto the 'Post' entity for the server schema + const posts = (await this.prepareRecords({ + createRaws: linkedRawPosts, + database, + recordOperator: preparePostRecord, + tableName, + })) as Post[]; + + // Appends the processed records into the final batch array + batch = batch.concat(posts); + + // Starts extracting information from each post to build up for related entities' data + for (const post of postsOrdered) { + // PostInThread handler: checks for id === root_id , if so, then call PostsInThread operator + if (!post.root_id) { + postsInThread.push({ + earliest: post.create_at, + post_id: post.id, + }); + } + + if (post?.metadata && Object.keys(post?.metadata).length > 0) { + const metadata = post.metadata; + + // Extracts reaction from post's metadata + reactions = reactions.concat(metadata?.reactions ?? []); + + // Extracts emojis from post's metadata + emojis = emojis.concat(metadata?.emojis ?? []); + + // Extracts files from post's metadata + files = files.concat(metadata?.files ?? []); + + // Extracts images and embeds from post's metadata + if (metadata?.images) { + images.push({images: metadata.images, postId: post.id}); + } + + if (metadata?.embeds) { + embeds.push({embed: metadata.embeds, postId: post.id}); + } + } + } + + if (reactions.length) { + // calls handler for Reactions + const postReactions = (await this.handleReactions({reactions, prepareRecordsOnly: true})) as Reaction[]; + batch = batch.concat(postReactions); + } + + if (files.length) { + // calls handler for Files + const postFiles = await this.handleFiles({files, prepareRecordsOnly: true}); + batch = batch.concat(postFiles); + } + + if (images.length || embeds.length) { + // calls handler for postMetadata ( embeds and images ) + const postMetadata = await this.handlePostMetadata({ + images, + embeds, + prepareRecordsOnly: true, + }); + + batch = batch.concat(postMetadata); + } + + if (batch.length) { + await this.batchOperations({database, models: batch}); + } + + // LAST: calls handler for CustomEmojis, PostsInThread, PostsInChannel + if (emojis.length) { + await this.handleIsolatedEntity({ + tableName: IsolatedEntities.CUSTOM_EMOJI, + values: emojis, + prepareRecordsOnly: false, + }); + } + + if (postsInThread.length) { + await this.handlePostsInThread(postsInThread); + } + + if (postsOrdered.length) { + await this.handlePostsInChannel(postsOrdered); + } + } + + if (postsUnordered.length) { + // Truly update those posts that have a different update_at value + await this.handleEntityRecords({ + findMatchingRecordBy: isRecordPostEqualToRaw, + fieldName: 'id', + operator: preparePostRecord, + rawValues: postsUnordered, + tableName: POST, + prepareRecordsOnly: false, + }); + } + }; + + /** + * handleFiles: Handler responsible for the Create/Update operations occurring on the File entity from the 'Server' schema + * @param {HandleFilesArgs} handleFiles + * @param {RawFile[]} handleFiles.files + * @param {boolean} handleFiles.prepareRecordsOnly + * @returns {Promise} + */ + handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs) => { + if (!files.length) { + return []; + } + + const database = await this.getDatabase(FILE); + + const postFiles = await this.prepareRecords({ + createRaws: getRawRecordPairs(files), + database, + recordOperator: prepareFileRecord, + tableName: FILE, + }); + + if (prepareRecordsOnly) { + return postFiles; + } + + if (postFiles?.length) { + await this.batchOperations({database, models: [...postFiles]}); + } + + return []; + }; + + /** + * handlePostMetadata: Handler responsible for the Create/Update operations occurring on the PostMetadata entity from the 'Server' schema + * @param {HandlePostMetadataArgs} handlePostMetadata + * @param {{embed: RawEmbed[], postId: string}[] | undefined} handlePostMetadata.embeds + * @param {{images: Dictionary, postId: string}[] | undefined} handlePostMetadata.images + * @param {boolean} handlePostMetadata.prepareRecordsOnly + * @returns {Promise} + */ + handlePostMetadata = async ({embeds, images, prepareRecordsOnly}: HandlePostMetadataArgs) => { + const metadata: RawPostMetadata[] = []; + + if (images?.length) { + images.forEach((image) => { + const imageEntry = Object.entries(image.images); + metadata.push({ + data: {...imageEntry?.[0]?.[1], url: imageEntry?.[0]?.[0]}, + type: 'images', + postId: image.postId, + }); + }); + } + + if (embeds?.length) { + embeds.forEach((postEmbed) => { + postEmbed.embed.forEach((embed: RawEmbed) => { + metadata.push({ + data: {...embed.data}, + type: embed.type, + postId: postEmbed.postId, + }); + }); + }); + } + + if (!metadata.length) { + return []; + } + + const database = await this.getDatabase(POST_METADATA); + + const postMetas = await this.prepareRecords({ + createRaws: getRawRecordPairs(metadata), + database, + recordOperator: preparePostMetadataRecord, + tableName: POST_METADATA, + }); + + if (prepareRecordsOnly) { + return postMetas; + } + + if (postMetas?.length) { + await this.batchOperations({database, models: [...postMetas]}); + } + + return []; + }; + + /** + * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread entity from the 'Server' schema + * @param {RawPostsInThread[]} rootPosts + * @returns {Promise} + */ + handlePostsInThread = async (rootPosts: RawPostsInThread[]) => { + if (!rootPosts.length) { + return; + } + + const postIds = rootPosts.map((postThread) => postThread.post_id); + const rawPostsInThreads: RawPostsInThread[] = []; + + const database = await this.getDatabase(POSTS_IN_THREAD); + + // Retrieves all threads whereby their root_id can be one of the element in the postIds array + const threads = (await database.collections. + get(POST). + query(Q.where('root_id', Q.oneOf(postIds))). + fetch()) as Post[]; + + // The aim here is to find the last reply in that thread; hence the latest create_at value + rootPosts.forEach((rootPost) => { + const maxCreateAt: number = threads.reduce((max: number, thread: Post) => { + return thread.createAt > max ? thread.createAt : maxCreateAt; + }, 0); + + // Collects all 'raw' postInThreads objects that will be sent to the operatePostsInThread function + rawPostsInThreads.push({...rootPost, latest: maxCreateAt}); + }); + + if (rawPostsInThreads.length) { + const postInThreadRecords = (await this.prepareRecords({ + createRaws: getRawRecordPairs(rawPostsInThreads), + database, + recordOperator: preparePostInThreadRecord, + tableName: POSTS_IN_THREAD, + })) as PostsInThread[]; + + if (postInThreadRecords?.length) { + await this.batchOperations({database, models: postInThreadRecords}); + } + } + }; + + /** + * handlePostsInChannel: Handler responsible for the Create/Update operations occurring on the PostsInChannel entity from the 'Server' schema + * @param {RawPost[]} posts + * @returns {Promise} + */ + handlePostsInChannel = async (posts: RawPost[]) => { + // At this point, the parameter 'posts' is already a chain of posts. Now, we have to figure out how to plug it + // into existing chains in the PostsInChannel table + + if (!posts.length) { + return []; + } + + // Sort a clone of 'posts' array by create_at + const sortedPosts = [...posts].sort((a, b) => { + return a.create_at - b.create_at; + }); + + // The first element (beginning of chain) + const tipOfChain: RawPost = sortedPosts[0]; + + // Channel Id for this chain of posts + const channelId = tipOfChain.channel_id; + + // Find smallest 'create_at' value in chain + const earliest = tipOfChain.create_at; + + // Find highest 'create_at' value in chain; -1 means we are dealing with one item in the posts array + const latest = sortedPosts[sortedPosts.length - 1].create_at; + + const database = await this.getDatabase(POSTS_IN_CHANNEL); + + // Find the records in the PostsInChannel table that have a matching channel_id + // const chunks = (await database.collections.get(POSTS_IN_CHANNEL).query(Q.where('channel_id', channelId)).fetch()) as PostsInChannel[]; + const chunks = (await retrieveRecords({ + database, + tableName: POSTS_IN_CHANNEL, + condition: Q.where('channel_id', channelId), + })) as PostsInChannel[]; + + const createPostsInChannelRecord = async () => { + await this.executeInDatabase({ + createRaws: [{record: undefined, raw: {channel_id: channelId, earliest, latest}}], + tableName: POSTS_IN_CHANNEL, + recordOperator: preparePostsInChannelRecord, + }); + }; + + // chunk length 0; then it's a new chunk to be added to the PostsInChannel table + if (chunks.length === 0) { + await createPostsInChannelRecord(); + return []; + } + + // Sort chunks (in-place) by earliest field ( oldest to newest ) + chunks.sort((a, b) => { + return a.earliest - b.earliest; + }); + + let found = false; + let targetChunk: PostsInChannel; + + for (const chunk of chunks) { + // find if we should plug the chain before + if (earliest < chunk.earliest) { + found = true; + targetChunk = chunk; + } + + if (found) { + break; + } + } + + if (found) { + // We have a potential chunk to plug nearby + const potentialPosts = (await retrieveRecords({ + database, + tableName: POST, + condition: Q.where('create_at', earliest), + })) as Post[]; + + if (potentialPosts?.length > 0) { + const targetPost = potentialPosts[0]; + + // now we decide if we need to operate on the targetChunk or just create a new chunk + const isChainable = tipOfChain.prev_post_id === targetPost.previousPostId; + + if (isChainable) { + // Update this chunk's data in PostsInChannel table. earliest comes from tipOfChain while latest comes from chunk + await database.action(async () => { + await targetChunk.update((postInChannel) => { + postInChannel.earliest = earliest; + }); + }); + } else { + await createPostsInChannelRecord(); + return []; + } + } + } else { + await createPostsInChannelRecord(); + return []; + } + + return []; + }; +}; + +export default PostHandler; diff --git a/app/database/operator/handlers/team.test.ts b/app/database/operator/handlers/team.test.ts new file mode 100644 index 0000000000..7870a2c5f9 --- /dev/null +++ b/app/database/operator/handlers/team.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DataOperator} from '@database/operator'; +import { + isRecordMyTeamEqualToRaw, + isRecordSlashCommandEqualToRaw, + isRecordTeamChannelHistoryEqualToRaw, + isRecordTeamEqualToRaw, + isRecordTeamMembershipEqualToRaw, + isRecordTeamSearchHistoryEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareMyTeamRecord, + prepareSlashCommandRecord, + prepareTeamChannelHistoryRecord, + prepareTeamMembershipRecord, + prepareTeamRecord, + prepareTeamSearchHistoryRecord, +} from '@database/operator/prepareRecords/team'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; + +jest.mock('@database/manager'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** Operator: Team Handlers tests ***', () => { + it('=> HandleTeam: should write to TEAM entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'team_handler', setActive: true}); + + await DataOperator.handleTeam({ + teams: [ + { + id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby', + create_at: 1445538153952, + update_at: 1588876392150, + delete_at: 0, + display_name: 'Contributors', + name: 'core', + description: '', + email: '', + type: 'O', + company_name: '', + allowed_domains: '', + invite_id: 'codoy5s743rq5mk18i7u5dfdfksz7e', + allow_open_invite: true, + last_team_icon_update: 1525181587639, + scheme_id: 'hbwgrncq1pfcdkpotzidfdmarn95o', + group_constrained: null, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'id', + rawValues: [ + { + id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby', + create_at: 1445538153952, + update_at: 1588876392150, + delete_at: 0, + display_name: 'Contributors', + name: 'core', + description: '', + email: '', + type: 'O', + company_name: '', + allowed_domains: '', + invite_id: 'codoy5s743rq5mk18i7u5dfdfksz7e', + allow_open_invite: true, + last_team_icon_update: 1525181587639, + scheme_id: 'hbwgrncq1pfcdkpotzidfdmarn95o', + group_constrained: null, + }, + ], + tableName: 'Team', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordTeamEqualToRaw, + operator: prepareTeamRecord, + }); + }); + + it('=> HandleTeamMemberships: should write to TEAM_MEMBERSHIP entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'team_handler', setActive: true}); + + await DataOperator.handleTeamMemberships({ + teamMemberships: [ + { + team_id: 'a', + user_id: 'ab', + roles: '3ngdqe1e7tfcbmam4qgnxp91bw', + delete_at: 0, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'user_id', + rawValues: [ + { + team_id: 'a', + user_id: 'ab', + roles: '3ngdqe1e7tfcbmam4qgnxp91bw', + delete_at: 0, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + ], + tableName: 'TeamMembership', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordTeamMembershipEqualToRaw, + operator: prepareTeamMembershipRecord, + }); + }); + + it('=> HandleMyTeam: should write to MY_TEAM entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'team_handler', setActive: true}); + + await DataOperator.handleMyTeam({ + myTeams: [ + { + team_id: 'teamA', + roles: 'roleA, roleB, roleC', + is_unread: true, + mentions_count: 3, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'team_id', + rawValues: [ + { + team_id: 'teamA', + roles: 'roleA, roleB, roleC', + is_unread: true, + mentions_count: 3, + }, + ], + tableName: 'MyTeam', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordMyTeamEqualToRaw, + operator: prepareMyTeamRecord, + }); + }); + + it('=> HandleTeamChannelHistory: should write to TEAM_CHANNEL_HISTORY entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'team_handler', setActive: true}); + + await DataOperator.handleTeamChannelHistory({ + teamChannelHistories: [ + { + team_id: 'a', + channel_ids: ['ca', 'cb'], + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'team_id', + rawValues: [{team_id: 'a', channel_ids: ['ca', 'cb']}], + tableName: 'TeamChannelHistory', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordTeamChannelHistoryEqualToRaw, + operator: prepareTeamChannelHistoryRecord, + }); + }); + + it('=> HandleTeamSearchHistory: should write to TEAM_SEARCH_HISTORY entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'team_handler', setActive: true}); + + await DataOperator.handleTeamSearchHistory({ + teamSearchHistories: [ + { + team_id: 'a', + term: 'termA', + display_term: 'termA', + created_at: 1445538153952, + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'team_id', + rawValues: [ + { + team_id: 'a', + term: 'termA', + display_term: 'termA', + created_at: 1445538153952, + }, + ], + tableName: 'TeamSearchHistory', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordTeamSearchHistoryEqualToRaw, + operator: prepareTeamSearchHistoryRecord, + }); + }); + + it('=> HandleSlashCommand: should write to SLASH_COMMAND entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'team_handler', setActive: true}); + + await DataOperator.handleSlashCommand({ + slashCommands: [ + { + id: 'command_1', + auto_complete: true, + auto_complete_desc: 'mock_command', + auto_complete_hint: 'hint', + create_at: 1445538153952, + creator_id: 'creator_id', + delete_at: 1445538153952, + description: 'description', + display_name: 'display_name', + icon_url: 'display_name', + method: 'get', + team_id: 'teamA', + token: 'token', + trigger: 'trigger', + update_at: 1445538153953, + url: 'url', + username: 'userA', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'id', + rawValues: [ + { + id: 'command_1', + auto_complete: true, + auto_complete_desc: 'mock_command', + auto_complete_hint: 'hint', + create_at: 1445538153952, + creator_id: 'creator_id', + delete_at: 1445538153952, + description: 'description', + display_name: 'display_name', + icon_url: 'display_name', + method: 'get', + team_id: 'teamA', + token: 'token', + trigger: 'trigger', + update_at: 1445538153953, + url: 'url', + username: 'userA', + }, + ], + tableName: 'SlashCommand', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordSlashCommandEqualToRaw, + operator: prepareSlashCommandRecord, + }); + }); +}); diff --git a/app/database/operator/handlers/team.ts b/app/database/operator/handlers/team.ts new file mode 100644 index 0000000000..5316849ace --- /dev/null +++ b/app/database/operator/handlers/team.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import { + isRecordMyTeamEqualToRaw, + isRecordSlashCommandEqualToRaw, + isRecordTeamChannelHistoryEqualToRaw, + isRecordTeamEqualToRaw, + isRecordTeamMembershipEqualToRaw, + isRecordTeamSearchHistoryEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareMyTeamRecord, + prepareSlashCommandRecord, + prepareTeamChannelHistoryRecord, + prepareTeamMembershipRecord, + prepareTeamRecord, + prepareTeamSearchHistoryRecord, +} from '@database/operator/prepareRecords/team'; +import {getUniqueRawsBy} from '@database/operator/utils/general'; +import { + HandleMyTeamArgs, + HandleSlashCommandArgs, + HandleTeamArgs, + HandleTeamChannelHistoryArgs, + HandleTeamMembershipArgs, + HandleTeamSearchHistoryArgs, +} from '@typings/database/database'; +import MyTeam from '@typings/database/my_team'; +import SlashCommand from '@typings/database/slash_command'; +import Team from '@typings/database/team'; +import TeamChannelHistory from '@typings/database/team_channel_history'; +import TeamMembership from '@typings/database/team_membership'; + +const { + MY_TEAM, + SLASH_COMMAND, + TEAM, + TEAM_CHANNEL_HISTORY, + TEAM_MEMBERSHIP, + TEAM_SEARCH_HISTORY, +} = MM_TABLES.SERVER; + +export interface TeamHandlerMix { + handleTeamMemberships : ({teamMemberships, prepareRecordsOnly}: HandleTeamMembershipArgs) => TeamMembership[] | boolean, + handleTeam: ({teams, prepareRecordsOnly}: HandleTeamArgs) => Team[] | boolean + handleTeamChannelHistory : ({teamChannelHistories, prepareRecordsOnly}: HandleTeamChannelHistoryArgs) => TeamChannelHistory[]| boolean, + handleSlashCommand : ({slashCommands, prepareRecordsOnly} : HandleSlashCommandArgs) => SlashCommand[]| boolean, + handleMyTeam : ({myTeams, prepareRecordsOnly}: HandleMyTeamArgs) => MyTeam[]| boolean +} + +const TeamHandler = (superclass: any) => class extends superclass { + /** + * handleTeamMemberships: Handler responsible for the Create/Update operations occurring on the TEAM_MEMBERSHIP entity from the 'Server' schema + * @param {HandleTeamMembershipArgs} teamMembershipsArgs + * @param {RawTeamMembership[]} teamMembershipsArgs.teamMemberships + * @param {boolean} teamMembershipsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {TeamMembership[] | boolean} + */ + handleTeamMemberships = async ({teamMemberships, prepareRecordsOnly = true}: HandleTeamMembershipArgs) => { + if (!teamMemberships.length) { + throw new DataOperatorException( + 'An empty "teamMemberships" array has been passed to the handleTeamMemberships method', + ); + } + + const rawValues = getUniqueRawsBy({raws: teamMemberships, key: 'team_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'user_id', + findMatchingRecordBy: isRecordTeamMembershipEqualToRaw, + operator: prepareTeamMembershipRecord, + rawValues, + tableName: TEAM_MEMBERSHIP, + prepareRecordsOnly, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleTeam: Handler responsible for the Create/Update operations occurring on the TEAM entity from the 'Server' schema + * @param {HandleTeamArgs} teamsArgs + * @param {RawTeam[]} teamsArgs.teams + * @param {boolean} teamsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Team[] | boolean} + */ + handleTeam = async ({teams, prepareRecordsOnly = true}: HandleTeamArgs) => { + if (!teams.length) { + throw new DataOperatorException( + 'An empty "teams" array has been passed to the handleTeam method', + ); + } + + const rawValues = getUniqueRawsBy({raws: teams, key: 'id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordTeamEqualToRaw, + operator: prepareTeamRecord, + prepareRecordsOnly, + rawValues, + tableName: TEAM, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleTeamChannelHistory: Handler responsible for the Create/Update operations occurring on the TEAM_CHANNEL_HISTORY entity from the 'Server' schema + * @param {HandleTeamChannelHistoryArgs} teamChannelHistoriesArgs + * @param {RawTeamChannelHistory[]} teamChannelHistoriesArgs.teamChannelHistories + * @param {boolean} teamChannelHistoriesArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {TeamChannelHistory[]| boolean} + */ + handleTeamChannelHistory = async ({teamChannelHistories, prepareRecordsOnly = true}: HandleTeamChannelHistoryArgs) => { + if (!teamChannelHistories.length) { + throw new DataOperatorException( + 'An empty "teamChannelHistories" array has been passed to the handleTeamChannelHistory method', + ); + } + + const rawValues = getUniqueRawsBy({raws: teamChannelHistories, key: 'team_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'team_id', + findMatchingRecordBy: isRecordTeamChannelHistoryEqualToRaw, + operator: prepareTeamChannelHistoryRecord, + prepareRecordsOnly, + rawValues, + tableName: TEAM_CHANNEL_HISTORY, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleTeamSearchHistory: Handler responsible for the Create/Update operations occurring on the TEAM_SEARCH_HISTORY entity from the 'Server' schema + * @param {HandleTeamSearchHistoryArgs} teamSearchHistoriesArgs + * @param {RawTeamSearchHistory[]} teamSearchHistoriesArgs.teamSearchHistories + * @param {boolean} teamSearchHistoriesArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {TeamSearchHistory[]| boolean} + */ + handleTeamSearchHistory = async ({teamSearchHistories, prepareRecordsOnly = true}: HandleTeamSearchHistoryArgs) => { + if (!teamSearchHistories.length) { + throw new DataOperatorException( + 'An empty "teamSearchHistories" array has been passed to the handleTeamSearchHistory method', + ); + } + + const rawValues = getUniqueRawsBy({raws: teamSearchHistories, key: 'term'}); + + const records = await this.handleEntityRecords({ + fieldName: 'team_id', + findMatchingRecordBy: isRecordTeamSearchHistoryEqualToRaw, + operator: prepareTeamSearchHistoryRecord, + prepareRecordsOnly, + rawValues, + tableName: TEAM_SEARCH_HISTORY, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleSlashCommand: Handler responsible for the Create/Update operations occurring on the SLASH_COMMAND entity from the 'Server' schema + * @param {HandleSlashCommandArgs} slashCommandsArgs + * @param {RawSlashCommand[]} slashCommandsArgs.slashCommands + * @param {boolean} slashCommandsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {SlashCommand[]| boolean} + */ + handleSlashCommand = async ({slashCommands, prepareRecordsOnly = true} : HandleSlashCommandArgs) => { + if (!slashCommands.length) { + throw new DataOperatorException( + 'An empty "slashCommands" array has been passed to the handleSlashCommand method', + ); + } + + const rawValues = getUniqueRawsBy({raws: slashCommands, key: 'id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordSlashCommandEqualToRaw, + operator: prepareSlashCommandRecord, + prepareRecordsOnly, + rawValues, + tableName: SLASH_COMMAND, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleMyTeam: Handler responsible for the Create/Update operations occurring on the MY_TEAM entity from the 'Server' schema + * @param {HandleMyTeamArgs} myTeamsArgs + * @param {RawMyTeam[]} myTeamsArgs.myTeams + * @param {boolean} myTeamsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {MyTeam[]| boolean} + */ + handleMyTeam = async ({myTeams, prepareRecordsOnly = true}: HandleMyTeamArgs) => { + if (!myTeams.length) { + throw new DataOperatorException( + 'An empty "myTeams" array has been passed to the handleSlashCommand method', + ); + } + + const rawValues = getUniqueRawsBy({raws: myTeams, key: 'team_id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'team_id', + findMatchingRecordBy: isRecordMyTeamEqualToRaw, + operator: prepareMyTeamRecord, + prepareRecordsOnly, + rawValues, + tableName: MY_TEAM, + }); + + return prepareRecordsOnly && records?.length && records; + }; +}; + +export default TeamHandler; diff --git a/app/database/operator/handlers/user.test.ts b/app/database/operator/handlers/user.test.ts new file mode 100644 index 0000000000..d317ca0c42 --- /dev/null +++ b/app/database/operator/handlers/user.test.ts @@ -0,0 +1,332 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DataOperator} from '@database/operator'; +import { + isRecordChannelMembershipEqualToRaw, + isRecordPreferenceEqualToRaw, + isRecordUserEqualToRaw, +} from '@database/operator/comparators'; +import { + prepareChannelMembershipRecord, + preparePreferenceRecord, + prepareUserRecord, +} from '@database/operator/prepareRecords/user'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; + +jest.mock('@database/manager'); + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** Operator: User Handlers tests ***', () => { + it('=> HandleReactions: should write to both Reactions and CustomEmoji entities', async () => { + expect.assertions(2); + + await createTestConnection({databaseName: 'user_handler', setActive: true}); + + const spyOnPrepareRecords = jest.spyOn(DataOperator as any, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); + + await DataOperator.handleReactions({ + reactions: [ + { + create_at: 1608263728086, + delete_at: 0, + emoji_name: 'p4p1', + post_id: '4r9jmr7eqt8dxq3f9woypzurry', + update_at: 1608263728077, + user_id: 'ooumoqgq3bfiijzwbn8badznwc', + }, + ], + prepareRecordsOnly: false, + }); + + // Called twice: Once for Reaction record and once for CustomEmoji record + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(2); + + // Only one batch operation for both entities + expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); + }); + + it('=> HandleUsers: should write to User entity', async () => { + expect.assertions(2); + + const users = [ + { + id: '9ciscaqbrpd6d8s68k76xb9bte', + create_at: 1599457495881, + update_at: 1607683720173, + delete_at: 0, + username: 'a.l', + auth_service: 'saml', + email: 'a.l@mattermost.com', + email_verified: true, + is_bot: false, + nickname: '', + first_name: 'A', + last_name: 'L', + position: 'Mobile Engineer', + roles: 'system_user', + props: {}, + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + auto_responder_active: false, + auto_responder_message: 'Hello, I am out of office and unable to respond to messages.', + comments: 'never', + desktop_notification_sound: 'Hello', + push_status: 'online', + }, + last_password_update: 1604323112537, + last_picture_update: 1604686302260, + locale: 'en', + timezone: { + automaticTimezone: 'Indian/Mauritius', + manualTimezone: '', + useAutomaticTimezone: true, + }, + }, + ]; + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'user_handler', setActive: true}); + + await DataOperator.handleUsers({users, prepareRecordsOnly: false}); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'id', + rawValues: [ + { + id: '9ciscaqbrpd6d8s68k76xb9bte', + create_at: 1599457495881, + update_at: 1607683720173, + delete_at: 0, + username: 'a.l', + auth_service: 'saml', + email: 'a.l@mattermost.com', + email_verified: true, + is_bot: false, + nickname: '', + first_name: 'A', + last_name: 'L', + position: 'Mobile Engineer', + roles: 'system_user', + props: {}, + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + auto_responder_active: false, + auto_responder_message: 'Hello, I am out of office and unable to respond to messages.', + comments: 'never', + desktop_notification_sound: 'Hello', + push_status: 'online', + }, + last_password_update: 1604323112537, + last_picture_update: 1604686302260, + locale: 'en', + timezone: { + automaticTimezone: 'Indian/Mauritius', + manualTimezone: '', + useAutomaticTimezone: true, + }, + }, + ], + tableName: 'User', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordUserEqualToRaw, + operator: prepareUserRecord, + }); + }); + + it('=> HandlePreferences: should write to PREFERENCE entity', async () => { + expect.assertions(2); + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'user_handler', setActive: true}); + + await DataOperator.handlePreferences({ + preferences: [ + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'group_channel_show', + name: 'qj91hepgjfn6xr4acm5xzd8zoc', + value: 'true', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'notifications', + name: 'email_interval', + value: '30', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'theme', + name: '', + value: + '{"awayIndicator":"#c1b966","buttonBg":"#4cbba4","buttonColor":"#ffffff","centerChannelBg":"#2f3e4e","centerChannelColor":"#dddddd","codeTheme":"solarized-dark","dndIndicator":"#e81023","errorTextColor":"#ff6461","image":"/static/files/0b8d56c39baf992e5e4c58d74fde0fd6.png","linkColor":"#a4ffeb","mentionBg":"#b74a4a","mentionColor":"#ffffff","mentionHighlightBg":"#984063","mentionHighlightLink":"#a4ffeb","newMessageSeparator":"#5de5da","onlineIndicator":"#65dcc8","sidebarBg":"#1b2c3e","sidebarHeaderBg":"#1b2c3e","sidebarHeaderTextColor":"#ffffff","sidebarText":"#ffffff","sidebarTextActiveBorder":"#66b9a7","sidebarTextActiveColor":"#ffffff","sidebarTextHoverBg":"#4a5664","sidebarUnreadText":"#ffffff","type":"Mattermost Dark"}', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'tutorial_step', + name: '9ciscaqbrpd6d8s68k76xb9bte', + value: '2', + }, + ], + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'user_id', + rawValues: [ + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'group_channel_show', + name: 'qj91hepgjfn6xr4acm5xzd8zoc', + value: 'true', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'notifications', + name: 'email_interval', + value: '30', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'theme', + name: '', + value: '{"awayIndicator":"#c1b966","buttonBg":"#4cbba4","buttonColor":"#ffffff","centerChannelBg":"#2f3e4e","centerChannelColor":"#dddddd","codeTheme":"solarized-dark","dndIndicator":"#e81023","errorTextColor":"#ff6461","image":"/static/files/0b8d56c39baf992e5e4c58d74fde0fd6.png","linkColor":"#a4ffeb","mentionBg":"#b74a4a","mentionColor":"#ffffff","mentionHighlightBg":"#984063","mentionHighlightLink":"#a4ffeb","newMessageSeparator":"#5de5da","onlineIndicator":"#65dcc8","sidebarBg":"#1b2c3e","sidebarHeaderBg":"#1b2c3e","sidebarHeaderTextColor":"#ffffff","sidebarText":"#ffffff","sidebarTextActiveBorder":"#66b9a7","sidebarTextActiveColor":"#ffffff","sidebarTextHoverBg":"#4a5664","sidebarUnreadText":"#ffffff","type":"Mattermost Dark"}', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'tutorial_step', + name: '9ciscaqbrpd6d8s68k76xb9bte', + value: '2', + }, + ], + tableName: 'Preference', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordPreferenceEqualToRaw, + operator: preparePreferenceRecord, + }); + }); + + it('=> HandleChannelMembership: should write to CHANNEL_MEMBERSHIP entity', async () => { + expect.assertions(2); + const channelMemberships = [ + { + channel_id: '17bfnb1uwb8epewp4q3x3rx9go', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'wqyby5r5pinxxdqhoaomtacdhc', + last_viewed_at: 1613667352029, + msg_count: 3864, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'mention', + push: 'default', + }, + last_update_at: 1613667352029, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + { + channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'channel_user', + last_viewed_at: 1615300540549, + msg_count: 16, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'all', + push: 'default', + }, + last_update_at: 1615300540549, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + ]; + + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + + await createTestConnection({databaseName: 'user_handler', setActive: true}); + + await DataOperator.handleChannelMembership({ + channelMemberships, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'user_id', + rawValues: [ + { + channel_id: '17bfnb1uwb8epewp4q3x3rx9go', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'wqyby5r5pinxxdqhoaomtacdhc', + last_viewed_at: 1613667352029, + msg_count: 3864, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'mention', + push: 'default', + }, + last_update_at: 1613667352029, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + { + channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'channel_user', + last_viewed_at: 1615300540549, + msg_count: 16, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'all', + push: 'default', + }, + last_update_at: 1615300540549, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + ], + tableName: 'ChannelMembership', + prepareRecordsOnly: false, + findMatchingRecordBy: isRecordChannelMembershipEqualToRaw, + operator: prepareChannelMembershipRecord, + }); + }); +}); diff --git a/app/database/operator/handlers/user.ts b/app/database/operator/handlers/user.ts new file mode 100644 index 0000000000..84566b28f9 --- /dev/null +++ b/app/database/operator/handlers/user.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import { + isRecordChannelMembershipEqualToRaw, + isRecordPreferenceEqualToRaw, + isRecordUserEqualToRaw, +} from '@database/operator/comparators'; +import {prepareCustomEmojiRecord} from '@database/operator/prepareRecords/general'; +import { + prepareChannelMembershipRecord, + preparePreferenceRecord, + prepareReactionRecord, + prepareUserRecord, +} from '@database/operator/prepareRecords/user'; +import {getRawRecordPairs, getUniqueRawsBy} from '@database/operator/utils/general'; +import {sanitizeReactions} from '@database/operator/utils/reaction'; +import Model from '@nozbe/watermelondb/Model'; +import ChannelMembership from '@typings/database/channel_membership'; +import CustomEmoji from '@typings/database/custom_emoji'; +import { + HandleChannelMembershipArgs, + HandlePreferencesArgs, + HandleReactionsArgs, + HandleUsersArgs, + RawReaction, +} from '@typings/database/database'; +import Preference from '@typings/database/preference'; +import Reaction from '@typings/database/reaction'; +import User from '@typings/database/user'; + +const { + CHANNEL_MEMBERSHIP, + CUSTOM_EMOJI, + PREFERENCE, + REACTION, + USER, +} = MM_TABLES.SERVER; + +export interface UserHandlerMix { + handleChannelMembership : ({channelMemberships, prepareRecordsOnly}: HandleChannelMembershipArgs) => ChannelMembership[] | boolean, + handlePreferences : ({preferences, prepareRecordsOnly}: HandlePreferencesArgs) => Preference[] | boolean, + handleReactions : ({reactions, prepareRecordsOnly}: HandleReactionsArgs) => boolean | (Reaction | CustomEmoji)[], + handleUsers : ({users, prepareRecordsOnly}: HandleUsersArgs) => User[] | boolean +} + +const UserHandler = (superclass: any) => class extends superclass { + /** + * handleChannelMembership: Handler responsible for the Create/Update operations occurring on the CHANNEL_MEMBERSHIP entity from the 'Server' schema + * @param {HandleChannelMembershipArgs} channelMembershipsArgs + * @param {RawChannelMembership[]} channelMembershipsArgs.channelMemberships + * @param {boolean} channelMembershipsArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {ChannelMembership[] | boolean} + */ + handleChannelMembership = async ({channelMemberships, prepareRecordsOnly = true}: HandleChannelMembershipArgs) => { + if (!channelMemberships.length) { + throw new DataOperatorException( + 'An empty "channelMemberships" array has been passed to the handleChannelMembership method', + ); + } + + const rawValues = getUniqueRawsBy({ + raws: channelMemberships, + key: 'channel_id', + }); + + const records = await this.handleEntityRecords({ + fieldName: 'user_id', + findMatchingRecordBy: isRecordChannelMembershipEqualToRaw, + operator: prepareChannelMembershipRecord, + prepareRecordsOnly, + rawValues, + tableName: CHANNEL_MEMBERSHIP, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handlePreferences: Handler responsible for the Create/Update operations occurring on the PREFERENCE entity from the 'Server' schema + * @param {HandlePreferencesArgs} preferencesArgs + * @param {RawPreference[]} preferencesArgs.preferences + * @param {boolean} preferencesArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Preference[] | boolean} + */ + handlePreferences = async ({preferences, prepareRecordsOnly = true}: HandlePreferencesArgs) => { + if (!preferences.length) { + throw new DataOperatorException( + 'An empty "preferences" array has been passed to the handlePreferences method', + ); + } + + const rawValues = getUniqueRawsBy({raws: preferences, key: 'name'}); + + const records = await this.handleEntityRecords({ + fieldName: 'user_id', + findMatchingRecordBy: isRecordPreferenceEqualToRaw, + operator: preparePreferenceRecord, + prepareRecordsOnly, + rawValues, + tableName: PREFERENCE, + }); + + return prepareRecordsOnly && records?.length && records; + }; + + /** + * handleReactions: Handler responsible for the Create/Update operations occurring on the Reaction entity from the 'Server' schema + * @param {HandleReactionsArgs} handleReactions + * @param {RawReaction[]} handleReactions.reactions + * @param {boolean} handleReactions.prepareRecordsOnly + * @throws DataOperatorException + * @returns {boolean | (Reaction | CustomEmoji)[]} + */ + handleReactions = async ({reactions, prepareRecordsOnly}: HandleReactionsArgs) => { + if (!reactions.length) { + throw new DataOperatorException( + 'An empty "reactions" array has been passed to the handleReactions method', + ); + } + + const rawValues = getUniqueRawsBy({raws: reactions, key: 'emoji_name'}) as RawReaction[]; + + const database = await this.getDatabase(REACTION); + + const { + createEmojis, + createReactions, + deleteReactions, + } = await sanitizeReactions({ + database, + post_id: reactions[0].post_id, + rawReactions: rawValues, + }); + + let batchRecords: Model[] = []; + + if (createReactions.length) { + // Prepares record for model Reactions + const reactionsRecords = (await this.prepareRecords({ + createRaws: createReactions, + database, + recordOperator: prepareReactionRecord, + tableName: REACTION, + })) as Reaction[]; + batchRecords = batchRecords.concat(reactionsRecords); + } + + if (createEmojis.length) { + // Prepares records for model CustomEmoji + const emojiRecords = (await this.prepareRecords({ + createRaws: getRawRecordPairs(createEmojis), + database, + recordOperator: prepareCustomEmojiRecord, + tableName: CUSTOM_EMOJI, + })) as CustomEmoji[]; + batchRecords = batchRecords.concat(emojiRecords); + } + + batchRecords = batchRecords.concat(deleteReactions); + + if (prepareRecordsOnly) { + return batchRecords; + } + + if (batchRecords?.length) { + await this.batchOperations({ + database, + models: batchRecords, + }); + } + + return false; + }; + + /** + * handleUsers: Handler responsible for the Create/Update operations occurring on the User entity from the 'Server' schema + * @param {HandleUsersArgs} usersArgs + * @param {RawUser[]} usersArgs.users + * @param {boolean} usersArgs.prepareRecordsOnly + * @throws DataOperatorException + * @returns {User[] | boolean} + */ + handleUsers = async ({users, prepareRecordsOnly = true}: HandleUsersArgs) => { + if (!users.length) { + throw new DataOperatorException( + 'An empty "users" array has been passed to the handleUsers method', + ); + } + + const rawValues = getUniqueRawsBy({raws: users, key: 'id'}); + + const records = await this.handleEntityRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordUserEqualToRaw, + operator: prepareUserRecord, + rawValues, + tableName: USER, + prepareRecordsOnly, + }); + + return prepareRecordsOnly && records?.length && records; + }; +}; + +export default UserHandler; diff --git a/app/database/operator/index.ts b/app/database/operator/index.ts new file mode 100644 index 0000000000..dfc2811c89 --- /dev/null +++ b/app/database/operator/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import BaseHandler, {BaseHandlerMix} from '@database/operator/handlers/base_handler'; +import ChannelHandler, {ChannelHandlerMix} from '@database/operator/handlers/channel'; +import GroupHandler, {GroupHandlerMix} from '@database/operator/handlers/group'; +import PostHandler, {PostHandlerMix} from '@database/operator/handlers/post'; +import TeamHandler, {TeamHandlerMix} from '@database/operator/handlers/team'; +import UserHandler, {UserHandlerMix} from '@database/operator/handlers/user'; +import mix from '@utils/mix'; + +interface Operator extends BaseHandlerMix, PostHandlerMix, UserHandlerMix, GroupHandlerMix, ChannelHandlerMix, TeamHandlerMix {} + +class Operator extends mix(BaseHandler).with( + PostHandler, + UserHandler, + GroupHandler, + ChannelHandler, + TeamHandler, +) {} + +const DataOperator = new Operator(); + +export {DataOperator, Operator}; diff --git a/app/database/operator/prepareRecords/channel.test.ts b/app/database/operator/prepareRecords/channel.test.ts new file mode 100644 index 0000000000..b26c460c32 --- /dev/null +++ b/app/database/operator/prepareRecords/channel.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + prepareChannelInfoRecord, + prepareChannelRecord, + prepareMyChannelRecord, + prepareMyChannelSettingsRecord, +} from '@database/operator/prepareRecords/channel'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {OperationType} from '@typings/database/enums'; + +describe('*** CHANNEL Prepare Records Test ***', () => { + it('=> prepareChannelRecord: should return an array of type Channel', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'channel_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareChannelRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'kow9j1ttnxwig7tnqgebg7dtipno', + create_at: 1600185541285, + update_at: 1604401077256, + delete_at: 0, + team_id: '', + type: 'D', + display_name: '', + name: 'jui1zkzkhh357b4bejephjz5u8daw__9ciscaqbrpd6d8s68k76xb9bte', + header: 'https://mattermost)', + purpose: '', + last_post_at: 1617311494451, + total_msg_count: 585, + extra_update_at: 0, + creator_id: '', + scheme_id: null, + props: null, + group_constrained: null, + shared: null, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords.collection.modelClass.name).toBe('Channel'); + }); + + it('=> prepareMyChannelSettingsRecord: should return an array of type MyChannelSettings', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'channel_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareMyChannelSettingsRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + channel_id: 'c', + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + }, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('MyChannelSettings'); + }); + + it('=> prepareChannelInfoRecord: should return an array of type ChannelInfo', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'channel_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareChannelInfoRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + channel_id: 'c', + guest_count: 10, + header: 'channel info header', + member_count: 10, + pinned_post_count: 3, + purpose: 'sample channel ', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('ChannelInfo'); + }); + + it('=> prepareMyChannelRecord: should return an array of type MyChannel', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'channel_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareMyChannelRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + channel_id: 'cd', + last_post_at: 1617311494451, + last_viewed_at: 1617311494451, + mentions_count: 3, + message_count: 10, + roles: 'guest', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('MyChannel'); + }); +}); diff --git a/app/database/operator/prepareRecords/channel.ts b/app/database/operator/prepareRecords/channel.ts new file mode 100644 index 0000000000..f934390c40 --- /dev/null +++ b/app/database/operator/prepareRecords/channel.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/prepareRecords/index'; +import Channel from '@typings/database/channel'; +import ChannelInfo from '@typings/database/channel_info'; +import { + DataFactoryArgs, + RawChannel, + RawChannelInfo, + RawMyChannel, + RawMyChannelSettings, +} from '@typings/database/database'; +import {OperationType} from '@typings/database/enums'; +import MyChannel from '@typings/database/my_channel'; +import MyChannelSettings from '@typings/database/my_channel_settings'; + +const { + CHANNEL, + CHANNEL_INFO, + MY_CHANNEL, + MY_CHANNEL_SETTINGS, +} = MM_TABLES.SERVER; + +/** + * prepareChannelRecord: Prepares record of entity 'CHANNEL' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareChannelRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawChannel; + const record = value.record as Channel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (channel: Channel) => { + channel._raw.id = isCreateAction ? (raw?.id ?? channel.id) : record.id; + channel.createAt = raw.create_at; + channel.creatorId = raw.creator_id; + channel.deleteAt = raw.delete_at; + channel.displayName = raw.display_name; + channel.isGroupConstrained = Boolean(raw.group_constrained); + channel.name = raw.name; + channel.teamId = raw.team_id; + channel.type = raw.type; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CHANNEL, + value, + generator, + }); +}; + +/** + * prepareMyChannelSettingsRecord: Prepares record of entity 'MY_CHANNEL_SETTINGS' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareMyChannelSettingsRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawMyChannelSettings; + const record = value.record as MyChannelSettings; + const isCreateAction = action === OperationType.CREATE; + + const generator = (myChannelSetting: MyChannelSettings) => { + myChannelSetting._raw.id = isCreateAction ? myChannelSetting.id : record.id; + myChannelSetting.channelId = raw.channel_id; + myChannelSetting.notifyProps = raw.notify_props; + }; + + return prepareBaseRecord({ + action, + database, + tableName: MY_CHANNEL_SETTINGS, + value, + generator, + }); +}; + +/** + * prepareChannelInfoRecord: Prepares record of entity 'CHANNEL_INFO' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareChannelInfoRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawChannelInfo; + const record = value.record as ChannelInfo; + const isCreateAction = action === OperationType.CREATE; + + const generator = (channelInfo: ChannelInfo) => { + channelInfo._raw.id = isCreateAction ? channelInfo.id : record.id; + channelInfo.channelId = raw.channel_id; + channelInfo.guestCount = raw.guest_count; + channelInfo.header = raw.header; + channelInfo.memberCount = raw.member_count; + channelInfo.pinned_post_count = raw.pinned_post_count; + channelInfo.purpose = raw.purpose; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CHANNEL_INFO, + value, + generator, + }); +}; + +/** + * prepareMyChannelRecord: Prepares record of entity 'MY_CHANNEL' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareMyChannelRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawMyChannel; + const record = value.record as MyChannel; + const isCreateAction = action === OperationType.CREATE; + + const generator = (myChannel: MyChannel) => { + myChannel._raw.id = isCreateAction ? myChannel.id : record.id; + myChannel.channelId = raw.channel_id; + myChannel.roles = raw.roles; + myChannel.messageCount = raw.message_count; + myChannel.mentionsCount = raw.mentions_count; + myChannel.lastPostAt = raw.last_post_at; + myChannel.lastViewedAt = raw.last_viewed_at; + }; + + return prepareBaseRecord({ + action, + database, + tableName: MY_CHANNEL, + value, + generator, + }); +}; + diff --git a/app/database/operator/prepareRecords/general.test.ts b/app/database/operator/prepareRecords/general.test.ts new file mode 100644 index 0000000000..5b86b75b5e --- /dev/null +++ b/app/database/operator/prepareRecords/general.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + prepareAppRecord, + prepareCustomEmojiRecord, + prepareGlobalRecord, + prepareRoleRecord, + prepareServersRecord, + prepareSystemRecord, + prepareTermsOfServiceRecord, +} from '@database/operator/prepareRecords/general'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import DatabaseManager from '@database/manager'; +import {OperationType} from '@typings/database/enums'; + +describe('*** Isolated Prepare Records Test ***', () => { + it('=> prepareAppRecord: should return an array of type App', async () => { + expect.assertions(3); + + const database = await DatabaseManager.getDefaultDatabase(); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareAppRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + build_number: 'build-7', + created_at: 1, + version_number: 'v-1', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('App'); + }); + + it('=> prepareGlobalRecord: should return an array of type Global', async () => { + expect.assertions(3); + + const database = await DatabaseManager.getDefaultDatabase(); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareGlobalRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: {name: 'g-n1', value: 'g-v1'}, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Global'); + }); + + it('=> prepareServersRecord: should return an array of type Servers', async () => { + expect.assertions(3); + + const database = await DatabaseManager.getDefaultDatabase(); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareServersRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + db_path: 'mm-server', + display_name: 's-displayName', + mention_count: 1, + unread_count: 0, + url: 'https://community.mattermost.com', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Servers'); + }); + + it('=> prepareRoleRecord: should return an array of type Role', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'isolated_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareRoleRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'role-1', + name: 'role-name-1', + permissions: [], + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Role'); + }); + + it('=> prepareSystemRecord: should return an array of type System', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'isolated_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareSystemRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: {id: 'system-1', name: 'system-name-1', value: 'system'}, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('System'); + }); + + it('=> prepareTermsOfServiceRecord: should return an array of type TermsOfService', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'isolated_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareTermsOfServiceRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'tos-1', + accepted_at: 1, + create_at: 1613667352029, + user_id: 'user1613667352029', + text: '', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('TermsOfService'); + }); + + it('=> prepareCustomEmojiRecord: should return an array of type CustomEmoji', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'isolated_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareCustomEmojiRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'i', + create_at: 1580913641769, + update_at: 1580913641769, + delete_at: 0, + creator_id: '4cprpki7ri81mbx8efixcsb8jo', + name: 'boomI', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('CustomEmoji'); + }); +}); diff --git a/app/database/operator/prepareRecords/general.ts b/app/database/operator/prepareRecords/general.ts new file mode 100644 index 0000000000..faf543f06c --- /dev/null +++ b/app/database/operator/prepareRecords/general.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/prepareRecords/index'; +import App from '@typings/database/app'; +import CustomEmoji from '@typings/database/custom_emoji'; +import { + DataFactoryArgs, + RawApp, + RawCustomEmoji, + RawGlobal, + RawRole, + RawServers, + RawSystem, + RawTermsOfService, +} from '@typings/database/database'; +import {OperationType} from '@typings/database/enums'; +import Global from '@typings/database/global'; +import Role from '@typings/database/role'; +import Servers from '@typings/database/servers'; +import System from '@typings/database/system'; +import TermsOfService from '@typings/database/terms_of_service'; + +const {APP, GLOBAL, SERVERS} = MM_TABLES.DEFAULT; +const { + CUSTOM_EMOJI, + ROLE, + SYSTEM, + TERMS_OF_SERVICE, + +} = MM_TABLES.SERVER; + +/** + * prepareAppRecord: Prepares record of entity 'App' from the DEFAULT database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareAppRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawApp; + const record = value.record as App; + const isCreateAction = action === OperationType.CREATE; + + const generator = (app: App) => { + app._raw.id = isCreateAction ? app.id : record.id; + app.buildNumber = raw?.build_number; + app.createdAt = raw?.created_at; + app.versionNumber = raw?.version_number; + }; + + return prepareBaseRecord({ + action, + database, + generator, + tableName: APP, + value, + }); +}; + +/** + * prepareGlobalRecord: Prepares record of entity 'Global' from the DEFAULT database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareGlobalRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGlobal; + const record = value.record as Global; + const isCreateAction = action === OperationType.CREATE; + + const generator = (global: Global) => { + global._raw.id = isCreateAction ? global.id : record.id; + global.name = raw?.name; + global.value = raw?.value; + }; + + return prepareBaseRecord({ + action, + database, + generator, + tableName: GLOBAL, + value, + }); +}; + +/** + * prepareServersRecord: Prepares record of entity 'Servers' from the DEFAULT database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareServersRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawServers; + const record = value.record as Servers; + const isCreateAction = action === OperationType.CREATE; + + const generator = (servers: Servers) => { + servers._raw.id = isCreateAction ? servers.id : record.id; + servers.dbPath = raw?.db_path; + servers.displayName = raw?.display_name; + servers.mentionCount = raw?.mention_count; + servers.unreadCount = raw?.unread_count; + servers.url = raw?.url; + }; + + return prepareBaseRecord({ + action, + database, + tableName: SERVERS, + value, + generator, + }); +}; + +/** + * prepareCustomEmojiRecord: Prepares record of entity 'CustomEmoji' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareCustomEmojiRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawCustomEmoji; + const record = value.record as CustomEmoji; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (emoji: CustomEmoji) => { + emoji._raw.id = isCreateAction ? (raw?.id ?? emoji.id) : record.id; + emoji.name = raw.name; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CUSTOM_EMOJI, + value, + generator, + }); +}; + +/** + * prepareRoleRecord: Prepares record of entity 'Role' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareRoleRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawRole; + const record = value.record as Role; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (role: Role) => { + role._raw.id = isCreateAction ? (raw?.id ?? role.id) : record.id; + role.name = raw?.name; + role.permissions = raw?.permissions; + }; + + return prepareBaseRecord({ + action, + database, + tableName: ROLE, + value, + generator, + }); +}; + +/** + * prepareSystemRecord: Prepares record of entity 'System' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareSystemRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawSystem; + const record = value.record as System; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (system: System) => { + system._raw.id = isCreateAction ? (raw?.id ?? system.id) : record.id; + system.name = raw?.name; + system.value = raw?.value; + }; + + return prepareBaseRecord({ + action, + database, + tableName: SYSTEM, + value, + generator, + }); +}; + +/** + * prepareTermsOfServiceRecord: Prepares record of entity 'TermsOfService' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareTermsOfServiceRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTermsOfService; + const record = value.record as TermsOfService; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (tos: TermsOfService) => { + tos._raw.id = isCreateAction ? (raw?.id ?? tos.id) : record.id; + tos.acceptedAt = raw?.accepted_at; + }; + + return prepareBaseRecord({ + action, + database, + tableName: TERMS_OF_SERVICE, + value, + generator, + }); +}; diff --git a/app/database/operator/prepareRecords/group.test.ts b/app/database/operator/prepareRecords/group.test.ts new file mode 100644 index 0000000000..a7b820fb0a --- /dev/null +++ b/app/database/operator/prepareRecords/group.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + prepareGroupMembershipRecord, + prepareGroupRecord, + prepareGroupsInChannelRecord, + prepareGroupsInTeamRecord, +} from '@database/operator/prepareRecords/group'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {OperationType} from '@typings/database/enums'; + +describe('*** GROUP Prepare Records Test ***', () => { + it('=> prepareGroupRecord: should return an array of type Group', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'group_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareGroupRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'id_groupdfjdlfkjdkfdsf', + name: 'mobile_team', + display_name: 'mobile team', + description: '', + source: '', + remote_id: '', + create_at: 0, + update_at: 0, + delete_at: 0, + has_syncables: true, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Group'); + }); + + it('=> prepareGroupsInTeamRecord: should return an array of type GroupsInTeam', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'group_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareGroupsInTeamRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + team_id: 'team_89', + team_display_name: '', + team_type: '', + group_id: 'group_id89', + auto_add: true, + create_at: 0, + delete_at: 0, + update_at: 0, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('GroupsInTeam'); + }); + + it('=> prepareGroupsInChannelRecord: should return an array of type GroupsInChannel', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'group_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareGroupsInChannelRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + auto_add: true, + channel_display_name: '', + channel_id: 'channelid', + channel_type: '', + create_at: 0, + delete_at: 0, + group_id: 'groupId', + team_display_name: '', + team_id: '', + team_type: '', + update_at: 0, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('GroupsInChannel'); + }); + + it('=> prepareGroupMembershipRecord: should return an array of type GroupMembership', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'group_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareGroupMembershipRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + user_id: 'u4cprpki7ri81mbx8efixcsb8jo', + group_id: 'g4cprpki7ri81mbx8efixcsb8jo', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('GroupMembership'); + }); +}); diff --git a/app/database/operator/prepareRecords/group.ts b/app/database/operator/prepareRecords/group.ts new file mode 100644 index 0000000000..5b6cdc5e2e --- /dev/null +++ b/app/database/operator/prepareRecords/group.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/prepareRecords/index'; +import { + DataFactoryArgs, + RawGroup, + RawGroupMembership, + RawGroupsInChannel, + RawGroupsInTeam, +} from '@typings/database/database'; +import {OperationType} from '@typings/database/enums'; +import Group from '@typings/database/group'; +import GroupMembership from '@typings/database/group_membership'; +import GroupsInChannel from '@typings/database/groups_in_channel'; +import GroupsInTeam from '@typings/database/groups_in_team'; + +const { + GROUP, + GROUPS_IN_CHANNEL, + GROUPS_IN_TEAM, + GROUP_MEMBERSHIP, +} = MM_TABLES.SERVER; + +/** + * prepareGroupMembershipRecord: Prepares record of entity 'GROUP_MEMBERSHIP' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareGroupMembershipRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGroupMembership; + const record = value.record as GroupMembership; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (groupMember: GroupMembership) => { + groupMember._raw.id = isCreateAction ? (raw?.id ?? groupMember.id) : record.id; + groupMember.groupId = raw.group_id; + groupMember.userId = raw.user_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: GROUP_MEMBERSHIP, + value, + generator, + }); +}; + +/** + * prepareGroupRecord: Prepares record of entity 'GROUP' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareGroupRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGroup; + const record = value.record as Group; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (group: Group) => { + group._raw.id = isCreateAction ? (raw?.id ?? group.id) : record.id; + group.name = raw.name; + group.displayName = raw.display_name; + }; + + return prepareBaseRecord({ + action, + database, + tableName: GROUP, + value, + generator, + }); +}; + +/** + * prepareGroupsInTeamRecord: Prepares record of entity 'GROUPS_IN_TEAM' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareGroupsInTeamRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGroupsInTeam; + const record = value.record as GroupsInTeam; + const isCreateAction = action === OperationType.CREATE; + + const generator = (groupsInTeam: GroupsInTeam) => { + groupsInTeam._raw.id = isCreateAction ? groupsInTeam.id : record.id; + groupsInTeam.teamId = raw.team_id; + groupsInTeam.groupId = raw.group_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: GROUPS_IN_TEAM, + value, + generator, + }); +}; + +/** + * prepareGroupsInChannelRecord: Prepares record of entity 'GROUPS_IN_CHANNEL' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareGroupsInChannelRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGroupsInChannel; + const record = value.record as GroupsInChannel; + const isCreateAction = action === OperationType.CREATE; + + const generator = (groupsInChannel: GroupsInChannel) => { + groupsInChannel._raw.id = isCreateAction ? groupsInChannel.id : record.id; + groupsInChannel.channelId = raw.channel_id; + groupsInChannel.groupId = raw.group_id; + groupsInChannel.memberCount = raw.member_count; + groupsInChannel.timezoneCount = raw.timezone_count; + }; + + return prepareBaseRecord({ + action, + database, + tableName: GROUPS_IN_CHANNEL, + value, + generator, + }); +}; diff --git a/app/database/operator/prepareRecords/index.ts b/app/database/operator/prepareRecords/index.ts new file mode 100644 index 0000000000..90416c0d5f --- /dev/null +++ b/app/database/operator/prepareRecords/index.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Model from '@nozbe/watermelondb/Model'; +import {DataFactoryArgs} from '@typings/database/database'; +import {OperationType} from '@typings/database/enums'; + +/** + * prepareBaseRecord: This is the last step for each operator and depending on the 'action', it will either prepare an + * existing record for UPDATE or prepare a collection for CREATE + * + * @param {DataFactoryArgs} operatorBase + * @param {Database} operatorBase.database + * @param {string} operatorBase.tableName + * @param {RecordPair} operatorBase.value + * @param {((DataFactoryArgs) => void)} operatorBase.generator + * @returns {Promise} + */ +export const prepareBaseRecord = async ({ + action, + database, + tableName, + value, + generator, +}: DataFactoryArgs): Promise => { + if (action === OperationType.UPDATE) { + const record = value.record as Model; + return record.prepareUpdate(() => generator!(record)); + } + + return database.collections.get(tableName!).prepareCreate(generator); +}; diff --git a/app/database/operator/prepareRecords/post.test.ts b/app/database/operator/prepareRecords/post.test.ts new file mode 100644 index 0000000000..d622cbe52b --- /dev/null +++ b/app/database/operator/prepareRecords/post.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + prepareDraftRecord, + prepareFileRecord, + preparePostInThreadRecord, + preparePostMetadataRecord, + preparePostRecord, + preparePostsInChannelRecord, +} from '@database/operator/prepareRecords/post'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {OperationType} from '@typings/database/enums'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('*** POST Prepare Records Test ***', () => { + it('=> preparePostRecord: should return an array of type Post', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await preparePostRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: '8swgtrrdiff89jnsiwiip3y1eoe', + create_at: 1596032651748, + update_at: 1596032651748, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: 'ps81iqbesfby8jayz7owg4yypoo', + parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', + original_id: '', + message: 'Testing operator post', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Post'); + }); + + it('=> preparePostInThreadRecord: should return an array of type PostsInThread', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await preparePostInThreadRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'ps81iqbddesfby8jayz7owg4yypoo', + post_id: '8swgtrrdiff89jnsiwiip3y1eoe', + earliest: 1596032651748, + latest: 1597032651748, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe( + 'PostsInThread', + ); + }); + + it('=> prepareFileRecord: should return an array of type File', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareFileRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + name: 'test_file', + extension: '.jpg', + size: 1000, + create_at: 1609253011321, + delete_at: 1609253011321, + height: 20, + update_at: 1609253011321, + user_id: 'wqyby5r5pinxxdqhoaomtacdhc', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('File'); + }); + + it('=> preparePostMetadataRecord: should return an array of type PostMetadata', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await preparePostMetadataRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'ps81i4yypoo', + data: {}, + postId: 'ps81iqbddesfby8jayz7owg4yypoo', + type: 'opengraph', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('PostMetadata'); + }); + + it('=> prepareDraftRecord: should return an array of type Draft', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareDraftRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'ps81i4yypoo', + root_id: 'ps81iqbddesfby8jayz7owg4yypoo', + message: 'draft message', + channel_id: 'channel_idp23232e', + files: [], + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Draft'); + }); + + it('=> preparePostsInChannelRecord: should return an array of type PostsInChannel', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await preparePostsInChannelRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'ps81i4yypoo', + channel_id: 'channel_idp23232e', + earliest: 1608253011321, + latest: 1609253011321, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe( + 'PostsInChannel', + ); + }); +}); diff --git a/app/database/operator/prepareRecords/post.ts b/app/database/operator/prepareRecords/post.ts new file mode 100644 index 0000000000..d6aea7af57 --- /dev/null +++ b/app/database/operator/prepareRecords/post.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/prepareRecords/index'; +import {Q} from '@nozbe/watermelondb'; +import { + DataFactoryArgs, + RawDraft, + RawFile, + RawPost, + RawPostMetadata, + RawPostsInChannel, + RawPostsInThread, +} from '@typings/database/database'; +import Draft from '@typings/database/draft'; +import {OperationType} from '@typings/database/enums'; +import File from '@typings/database/file'; +import Post from '@typings/database/post'; +import PostMetadata from '@typings/database/post_metadata'; +import PostsInChannel from '@typings/database/posts_in_channel'; +import PostsInThread from '@typings/database/posts_in_thread'; + +const { + DRAFT, + FILE, + POST, + POSTS_IN_CHANNEL, + POSTS_IN_THREAD, + POST_METADATA, +} = MM_TABLES.SERVER; + +/** + * preparePostRecord: Prepares record of entity 'Post' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const preparePostRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPost; + const record = value.record as Post; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (post: Post) => { + post._raw.id = isCreateAction ? (raw?.id ?? post.id) : record.id; + post.channelId = raw.channel_id; + post.createAt = raw.create_at; + post.deleteAt = raw.delete_at || raw.delete_at === 0 ? raw?.delete_at : 0; + post.editAt = raw.edit_at; + post.updateAt = raw.update_at; + post.isPinned = Boolean(raw.is_pinned); + post.message = Q.sanitizeLikeString(raw.message); + post.userId = raw.user_id; + post.originalId = raw.original_id; + post.pendingPostId = raw.pending_post_id; + post.previousPostId = raw.prev_post_id ?? ''; + post.rootId = raw.root_id; + post.type = raw.type ?? ''; + post.props = raw.props ?? {}; + }; + + return prepareBaseRecord({ + action, + database, + tableName: POST, + value, + generator, + }); +}; + +/** + * preparePostInThreadRecord: Prepares record of entity 'POSTS_IN_THREAD' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const preparePostInThreadRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPostsInThread; + const record = value.record as PostsInThread; + const isCreateAction = action === OperationType.CREATE; + + const generator = (postsInThread: PostsInThread) => { + postsInThread.postId = isCreateAction ? raw.post_id : record.id; + postsInThread.earliest = raw.earliest; + postsInThread.latest = raw.latest!; + }; + + return prepareBaseRecord({ + action, + database, + tableName: POSTS_IN_THREAD, + value, + generator, + }); +}; + +/** + * prepareFileRecord: Prepares record of entity 'FILE' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareFileRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawFile; + const record = value.record as File; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (file: File) => { + file._raw.id = isCreateAction ? (raw?.id ?? file.id) : record.id; + file.postId = raw.post_id; + file.name = raw.name; + file.extension = raw.extension; + file.size = raw.size; + file.mimeType = raw?.mime_type ?? ''; + file.width = raw?.width ?? 0; + file.height = raw?.height ?? 0; + file.imageThumbnail = raw?.mini_preview ?? ''; + file.localPath = raw?.localPath ?? ''; + }; + + return prepareBaseRecord({ + action, + database, + tableName: FILE, + value, + generator, + }); +}; + +/** + * preparePostMetadataRecord: Prepares record of entity 'POST_METADATA' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const preparePostMetadataRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPostMetadata; + const record = value.record as PostMetadata; + const isCreateAction = action === OperationType.CREATE; + + const generator = (postMeta: PostMetadata) => { + postMeta._raw.id = isCreateAction ? postMeta.id : record.id; + postMeta.data = raw.data; + postMeta.postId = raw.postId; + postMeta.type = raw.type; + }; + + return prepareBaseRecord({ + action, + database, + tableName: POST_METADATA, + value, + generator, + }); +}; + +/** + * prepareDraftRecord: Prepares record of entity 'DRAFT' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareDraftRecord = ({action, database, value}: DataFactoryArgs) => { + const emptyFileInfo: FileInfo[] = []; + const raw = value.raw as RawDraft; + + // We use the raw id as Draft is client side only and we would only be creating/deleting drafts + const generator = (draft: Draft) => { + draft._raw.id = draft.id; + draft.rootId = raw?.root_id ?? ''; + draft.message = raw?.message ?? ''; + draft.channelId = raw?.channel_id ?? ''; + draft.files = raw?.files ?? emptyFileInfo; + }; + + return prepareBaseRecord({ + action, + database, + tableName: DRAFT, + value, + generator, + }); +}; + +/** + * preparePostsInChannelRecord: Prepares record of entity 'POSTS_IN_CHANNEL' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const preparePostsInChannelRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPostsInChannel; + const record = value.record as PostsInChannel; + const isCreateAction = action === OperationType.CREATE; + + const generator = (postsInChannel: PostsInChannel) => { + postsInChannel._raw.id = isCreateAction ? postsInChannel.id : record.id; + postsInChannel.channelId = raw.channel_id; + postsInChannel.earliest = raw.earliest; + postsInChannel.latest = raw.latest; + }; + + return prepareBaseRecord({ + action, + database, + tableName: POSTS_IN_CHANNEL, + value, + generator, + }); +}; diff --git a/app/database/operator/prepareRecords/team.test.ts b/app/database/operator/prepareRecords/team.test.ts new file mode 100644 index 0000000000..7f51d30891 --- /dev/null +++ b/app/database/operator/prepareRecords/team.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + prepareMyTeamRecord, + prepareSlashCommandRecord, + prepareTeamChannelHistoryRecord, + prepareTeamMembershipRecord, + prepareTeamRecord, + prepareTeamSearchHistoryRecord, +} from '@database/operator/prepareRecords/team'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {OperationType} from '@typings/database/enums'; + +describe('*** TEAM Prepare Records Test ***', () => { + it('=> prepareSlashCommandRecord: should return an array of type SlashCommand', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'team_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareSlashCommandRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'command_1', + auto_complete: true, + auto_complete_desc: 'mock_command', + auto_complete_hint: 'hint', + create_at: 1445538153952, + creator_id: 'creator_id', + delete_at: 1445538153952, + description: 'description', + display_name: 'display_name', + icon_url: 'display_name', + method: 'get', + team_id: 'teamA', + token: 'token', + trigger: 'trigger', + update_at: 1445538153953, + url: 'url', + username: 'userA', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('SlashCommand'); + }); + + it('=> prepareMyTeamRecord: should return an array of type MyTeam', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'team_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareMyTeamRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + team_id: 'teamA', + roles: 'roleA, roleB, roleC', + is_unread: true, + mentions_count: 3, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('MyTeam'); + }); + + it('=> prepareTeamRecord: should return an array of type Team', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'team_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareTeamRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'rcgiyftm7jyrxnmdfdfa1osd8zswby', + create_at: 1445538153952, + update_at: 1588876392150, + delete_at: 0, + display_name: 'Contributors', + name: 'core', + description: '', + email: '', + type: 'O', + company_name: '', + allowed_domains: '', + invite_id: 'codoy5s743rq5mk18i7u5dfdfksz7e', + allow_open_invite: true, + last_team_icon_update: 1525181587639, + scheme_id: 'hbwgrncq1pfcdkpotzidfdmarn95o', + group_constrained: null, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Team'); + }); + + it('=> prepareTeamChannelHistoryRecord: should return an array of type Team', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'team_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareTeamChannelHistoryRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + team_id: 'a', + channel_ids: ['ca', 'cb'], + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('TeamChannelHistory'); + }); + + it('=> prepareTeamSearchHistoryRecord: should return an array of type TeamSearchHistory', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'team_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareTeamSearchHistoryRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + team_id: 'a', + term: 'termA', + display_term: 'termA', + created_at: 1445538153952, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('TeamSearchHistory'); + }); + + it('=> prepareTeamMembershipRecord: should return an array of type TeamMembership', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'team_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareTeamMembershipRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + team_id: 'a', + user_id: 'ab', + roles: '3ngdqe1e7tfcbmam4qgnxp91bw', + delete_at: 0, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('TeamMembership'); + }); +}); diff --git a/app/database/operator/prepareRecords/team.ts b/app/database/operator/prepareRecords/team.ts new file mode 100644 index 0000000000..05c8d4df72 --- /dev/null +++ b/app/database/operator/prepareRecords/team.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {MM_TABLES} from '@constants/database'; + +// See LICENSE.txt for license information. +import {prepareBaseRecord} from '@database/operator/prepareRecords/index'; +import { + DataFactoryArgs, + RawMyTeam, + RawSlashCommand, + RawTeam, + RawTeamChannelHistory, + RawTeamMembership, + RawTeamSearchHistory, +} from '@typings/database/database'; +import {OperationType} from '@typings/database/enums'; +import MyTeam from '@typings/database/my_team'; +import SlashCommand from '@typings/database/slash_command'; +import Team from '@typings/database/team'; +import TeamChannelHistory from '@typings/database/team_channel_history'; +import TeamMembership from '@typings/database/team_membership'; +import TeamSearchHistory from '@typings/database/team_search_history'; + +const { + MY_TEAM, + SLASH_COMMAND, + TEAM, + TEAM_CHANNEL_HISTORY, + TEAM_MEMBERSHIP, + TEAM_SEARCH_HISTORY, +} = MM_TABLES.SERVER; + +/** + * preparePreferenceRecord: Prepares record of entity 'TEAM_MEMBERSHIP' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareTeamMembershipRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTeamMembership; + const record = value.record as TeamMembership; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (teamMembership: TeamMembership) => { + teamMembership._raw.id = isCreateAction ? (raw?.id ?? teamMembership.id) : record.id; + teamMembership.teamId = raw.team_id; + teamMembership.userId = raw.user_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: TEAM_MEMBERSHIP, + value, + generator, + }); +}; + +/** + * prepareTeamRecord: Prepares record of entity 'TEAM' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareTeamRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTeam; + const record = value.record as Team; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (team: Team) => { + team._raw.id = isCreateAction ? (raw?.id ?? team.id) : record.id; + team.isAllowOpenInvite = raw.allow_open_invite; + team.description = raw.description; + team.displayName = raw.display_name; + team.name = raw.name; + team.updateAt = raw.update_at; + team.type = raw.type; + team.allowedDomains = raw.allowed_domains; + team.isGroupConstrained = Boolean(raw.group_constrained); + team.lastTeamIconUpdatedAt = raw.last_team_icon_update; + }; + + return prepareBaseRecord({ + action, + database, + tableName: TEAM, + value, + generator, + }); +}; + +/** + * prepareTeamChannelHistoryRecord: Prepares record of entity 'TEAM_CHANNEL_HISTORY' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareTeamChannelHistoryRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTeamChannelHistory; + const record = value.record as TeamChannelHistory; + const isCreateAction = action === OperationType.CREATE; + + const generator = (teamChannelHistory: TeamChannelHistory) => { + teamChannelHistory._raw.id = isCreateAction ? (teamChannelHistory.id) : record.id; + teamChannelHistory.teamId = raw.team_id; + teamChannelHistory.channelIds = raw.channel_ids; + }; + + return prepareBaseRecord({ + action, + database, + tableName: TEAM_CHANNEL_HISTORY, + value, + generator, + }); +}; + +/** + * prepareTeamSearchHistoryRecord: Prepares record of entity 'TEAM_SEARCH_HISTORY' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareTeamSearchHistoryRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTeamSearchHistory; + const record = value.record as TeamSearchHistory; + const isCreateAction = action === OperationType.CREATE; + + const generator = (teamSearchHistory: TeamSearchHistory) => { + teamSearchHistory._raw.id = isCreateAction ? (teamSearchHistory.id) : record.id; + teamSearchHistory.createdAt = raw.created_at; + teamSearchHistory.displayTerm = raw.display_term; + teamSearchHistory.term = raw.term; + teamSearchHistory.teamId = raw.team_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: TEAM_SEARCH_HISTORY, + value, + generator, + }); +}; + +/** + * prepareSlashCommandRecord: Prepares record of entity 'SLASH_COMMAND' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareSlashCommandRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawSlashCommand; + const record = value.record as SlashCommand; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (slashCommand: SlashCommand) => { + slashCommand._raw.id = isCreateAction ? (raw?.id ?? slashCommand.id) : record.id; + slashCommand.isAutoComplete = raw.auto_complete; + slashCommand.description = raw.description; + slashCommand.displayName = raw.display_name; + slashCommand.hint = raw.auto_complete_hint; + slashCommand.method = raw.method; + slashCommand.teamId = raw.team_id; + slashCommand.token = raw.token; + slashCommand.trigger = raw.trigger; + slashCommand.updateAt = raw.update_at; + }; + + return prepareBaseRecord({ + action, + database, + tableName: SLASH_COMMAND, + value, + generator, + }); +}; + +/** + * prepareMyTeamRecord: Prepares record of entity 'MY_TEAM' from the SERVER database for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareMyTeamRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawMyTeam; + const record = value.record as MyTeam; + const isCreateAction = action === OperationType.CREATE; + + const generator = (myTeam: MyTeam) => { + myTeam._raw.id = isCreateAction ? myTeam.id : record.id; + myTeam.teamId = raw.team_id; + myTeam.roles = raw.roles; + myTeam.isUnread = raw.is_unread; + myTeam.mentionsCount = raw.mentions_count; + }; + + return prepareBaseRecord({ + action, + database, + tableName: MY_TEAM, + value, + generator, + }); +}; diff --git a/app/database/operator/prepareRecords/user.test.ts b/app/database/operator/prepareRecords/user.test.ts new file mode 100644 index 0000000000..629ac50ad9 --- /dev/null +++ b/app/database/operator/prepareRecords/user.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import { + prepareChannelMembershipRecord, + preparePreferenceRecord, + prepareReactionRecord, + prepareUserRecord, +} from '@database/operator/prepareRecords/user'; + +// See LICENSE.txt for license information. +import {createTestConnection} from '@database/operator/utils/create_test_connection'; +import {OperationType} from '@typings/database/enums'; + +describe('*** USER Prepare Records Test ***', () => { + it('=> prepareChannelMembershipRecord: should return an array of type ChannelMembership', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'user_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareChannelMembershipRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + channel_id: '17bfnb1uwb8epewp4q3x3rx9go', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'wqyby5r5pinxxdqhoaomtacdhc', + last_viewed_at: 1613667352029, + msg_count: 3864, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'mention', + push: 'default', + }, + last_update_at: 1613667352029, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('ChannelMembership'); + }); + + it('=> preparePreferenceRecord: should return an array of type Preference', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'user_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await preparePreferenceRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: {user_id: '9ciscaqbrpd6d8s68k76xb9bte', category: 'tutorial_step', name: '9ciscaqbrpd6d8s68k76xb9bte', value: '2'}, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Preference'); + }); + + it('=> prepareReactionRecord: should return an array of type Reaction', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'user_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareReactionRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'ps81iqbddesfby8jayz7owg4yypoo', + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + emoji_name: 'thumbsup', + create_at: 1596032651748, + update_at: 1608253011321, + delete_at: 0, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('Reaction'); + }); + + it('=> prepareUserRecord: should return an array of type User', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'user_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await prepareUserRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: '9ciscaqbrpd6d8s68k76xb9bte', + is_bot: false, + create_at: 1599457495881, + update_at: 1607683720173, + delete_at: 0, + username: 'a.l', + auth_service: 'saml', + email: 'a.l@mattermost.com', + email_verified: true, + nickname: '', + first_name: 'A', + last_name: 'L', + position: 'Mobile Engineer', + roles: 'system_user', + props: {}, + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + auto_responder_active: false, + auto_responder_message: 'Hello, I am out of office and unable to respond to messages.', + comments: 'never', + desktop_notification_sound: 'Hello', + push_status: 'online', + }, + last_password_update: 1604323112537, + last_picture_update: 1604686302260, + locale: 'en', + timezone: { + automaticTimezone: 'Indian/Mauritius', + manualTimezone: '', + useAutomaticTimezone: true, + }, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toBe('User'); + }); +}); diff --git a/app/database/operator/prepareRecords/user.ts b/app/database/operator/prepareRecords/user.ts new file mode 100644 index 0000000000..a41f771371 --- /dev/null +++ b/app/database/operator/prepareRecords/user.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/prepareRecords/index'; +import ChannelMembership from '@typings/database/channel_membership'; +import {DataFactoryArgs, RawChannelMembership, RawPreference, RawReaction, RawUser} from '@typings/database/database'; +import {OperationType} from '@typings/database/enums'; +import Preference from '@typings/database/preference'; +import Reaction from '@typings/database/reaction'; +import User from '@typings/database/user'; + +const { + CHANNEL_MEMBERSHIP, + PREFERENCE, + REACTION, + USER, +} = MM_TABLES.SERVER; + +/** + * prepareReactionRecord: Prepares record of entity 'REACTION' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareReactionRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawReaction; + const record = value.record as Reaction; + const isCreateAction = action === OperationType.CREATE; + + // id of reaction comes from server response + const generator = (reaction: Reaction) => { + reaction._raw.id = isCreateAction ? (raw?.id ?? reaction.id) : record.id; + reaction.userId = raw.user_id; + reaction.postId = raw.post_id; + reaction.emojiName = raw.emoji_name; + reaction.createAt = raw.create_at; + }; + + return prepareBaseRecord({ + action, + database, + tableName: REACTION, + value, + generator, + }); +}; + +/** + * prepareUserRecord: Prepares record of entity 'USER' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareUserRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawUser; + const record = value.record as User; + const isCreateAction = action === OperationType.CREATE; + + // id of user comes from server response + const generator = (user: User) => { + user._raw.id = isCreateAction ? (raw?.id ?? user.id) : record.id; + user.authService = raw.auth_service; + user.deleteAt = raw.delete_at; + user.updateAt = raw.update_at; + user.email = raw.email; + user.firstName = raw.first_name; + user.isGuest = raw.roles.includes('system_guest'); + user.lastName = raw.last_name; + user.lastPictureUpdate = raw.last_picture_update; + user.locale = raw.locale; + user.nickname = raw.nickname; + user.position = raw?.position ?? ''; + user.roles = raw.roles; + user.username = raw.username; + user.notifyProps = raw.notify_props; + user.props = raw.props; + user.timezone = raw.timezone; + user.isBot = raw.is_bot; + }; + + return prepareBaseRecord({ + action, + database, + tableName: USER, + value, + generator, + }); +}; + +/** + * preparePreferenceRecord: Prepares record of entity 'PREFERENCE' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const preparePreferenceRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPreference; + const record = value.record as Preference; + const isCreateAction = action === OperationType.CREATE; + + // id of preference comes from server response + const generator = (preference: Preference) => { + preference._raw.id = isCreateAction ? (raw?.id ?? preference.id) : record.id; + preference.category = raw.category; + preference.name = raw.name; + preference.userId = raw.user_id; + preference.value = raw.value; + }; + + return prepareBaseRecord({ + action, + database, + tableName: PREFERENCE, + value, + generator, + }); +}; + +/** + * prepareChannelMembershipRecord: Prepares record of entity 'CHANNEL_MEMBERSHIP' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const prepareChannelMembershipRecord = ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawChannelMembership; + const record = value.record as ChannelMembership; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const generator = (channelMember: ChannelMembership) => { + channelMember._raw.id = isCreateAction ? (raw?.id ?? channelMember.id) : record.id; + channelMember.channelId = raw.channel_id; + channelMember.userId = raw.user_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CHANNEL_MEMBERSHIP, + value, + generator, + }); +}; diff --git a/app/database/operator/utils/create_test_connection.ts b/app/database/operator/utils/create_test_connection.ts new file mode 100644 index 0000000000..24e2d29afa --- /dev/null +++ b/app/database/operator/utils/create_test_connection.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {DatabaseType} from '@typings/database/enums'; + +// NOTE: uncomment the below line if you are manually testing the database +jest.mock('@database/manager'); + +export const createTestConnection = async ({databaseName = 'db_name', setActive = false}) => { + const serverUrl = 'https://appv2.mattermost.com'; + const database = await DatabaseManager.createDatabaseConnection({ + shouldAddToDefaultDatabase: true, + configs: { + actionsEnabled: true, + dbName: databaseName, + dbType: DatabaseType.SERVER, + serverUrl, + }, + }); + + if (setActive) { + await DatabaseManager.setActiveServerDatabase({ + displayName: databaseName, + serverUrl, + }); + } + + return database; +}; diff --git a/app/database/operator/utils/general.ts b/app/database/operator/utils/general.ts new file mode 100644 index 0000000000..c654a90765 --- /dev/null +++ b/app/database/operator/utils/general.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import Channel from '@typings/database/channel'; +import { + IdenticalRecordArgs, + RangeOfValueArgs, + RawChannel, + RawPost, + RawSlashCommand, + RawTeam, + RawUser, + RawValue, + RecordPair, + RetrieveRecordsArgs, +} from '@typings/database/database'; +import Post from '@typings/database/post'; +import SlashCommand from '@typings/database/slash_command'; +import Team from '@typings/database/team'; +import User from '@typings/database/user'; + +const {CHANNEL, POST, SLASH_COMMAND, TEAM, USER} = MM_TABLES.SERVER; + +/** + * hasSimilarUpdateAt: Database Operations on some entities are expensive. As such, we would like to operate if and only if we are + * 100% sure that the records are actually different from what we already have in the database. + * @param {IdenticalRecordArgs} identicalRecord + * @param {string} identicalRecord.tableName + * @param {RecordValue} identicalRecord.newValue + * @param {Model} identicalRecord.existingRecord + * @returns {boolean} + */ +export const hasSimilarUpdateAt = ({tableName, newValue, existingRecord}: IdenticalRecordArgs) => { + const guardTables = [CHANNEL, POST, SLASH_COMMAND, TEAM, USER]; + + if (guardTables.includes(tableName)) { + type Raw = RawPost | RawUser | RawTeam | RawSlashCommand | RawChannel; + type ExistingRecord = Post | User | Team | SlashCommand | Channel; + + return (newValue as Raw).update_at === (existingRecord as ExistingRecord).updateAt; + } + return false; +}; + +/** + * This method extracts one particular field 'fieldName' from the raw values and returns them as a string array + * @param {RangeOfValueArgs} range + * @param {string} range.fieldName + * @param {RawValue[]} range.raws + * @returns {string[]} + */ +export const getRangeOfValues = ({fieldName, raws}: RangeOfValueArgs) => { + return raws.reduce((oneOfs, current: RawValue) => { + const key = fieldName as keyof typeof current; + const value: string = current[key] as string; + if (value) { + oneOfs.push(value); + } + return oneOfs; + }, [] as string[]); +}; + +/** + * getRawRecordPairs: Utility method that maps over the raws array to create an array of RecordPair + * @param {any[]} raws + * @returns {{record: undefined, raw: any}[]} + */ +export const getRawRecordPairs = (raws: any[]): RecordPair[] => { + return raws.map((raw) => { + return {raw, record: undefined}; + }); +}; + +/** + * getUniqueRawsBy: We have to ensure that we are not updating the same record twice in the same operation. + * Hence, thought it might not occur, prevention is better than cure. This function removes duplicates from the 'raws' array. + * @param {RawValue[]} raws + * @param {string} key + */ +export const getUniqueRawsBy = ({raws, key}:{ raws: RawValue[], key: string}) => { + return [...new Map(raws.map((item) => { + const curItemKey = item[key as keyof typeof item]; + return [curItemKey, item]; + })).values()]; +}; + +/** + * retrieveRecords: Retrieves records from the database + * @param {RetrieveRecordsArgs} records + * @param {Database} records.database + * @param {string} records.tableName + * @param {any} records.condition + * @returns {Promise} + */ +export const retrieveRecords = ({database, tableName, condition}: RetrieveRecordsArgs) => { + return database.collections.get(tableName).query(condition).fetch(); +}; diff --git a/app/database/admin/data_operator/utils/mock.ts b/app/database/operator/utils/mock.ts similarity index 100% rename from app/database/admin/data_operator/utils/mock.ts rename to app/database/operator/utils/mock.ts diff --git a/app/database/operator/utils/post.ts b/app/database/operator/utils/post.ts new file mode 100644 index 0000000000..ca07c9bb83 --- /dev/null +++ b/app/database/operator/utils/post.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ChainPostsArgs, RawPost, RecordPair, SanitizePostsArgs} from '@typings/database/database'; + +/** + * sanitizePosts: Creates arrays of ordered and unordered posts. Unordered posts are those posts that are not + * present in the orders array + * @param {SanitizePostsArgs} sanitizePosts + * @param {RawPost[]} sanitizePosts.posts + * @param {string[]} sanitizePosts.orders + */ +export const sanitizePosts = ({posts, orders}: SanitizePostsArgs) => { + const orderedPosts: RawPost[] = []; + const unOrderedPosts: RawPost[] = []; + + posts.forEach((post) => { + if (post?.id && orders.includes(post.id)) { + orderedPosts.push(post); + } else { + unOrderedPosts.push(post); + } + }); + + return { + postsOrdered: orderedPosts, + postsUnordered: unOrderedPosts, + }; +}; + +/** + * createPostsChain: Basically creates the 'chain of posts' using the 'orders' array; each post is linked to the other + * by the previous_post_id field. + * @param {ChainPostsArgs} chainPosts + * @param {string[]} chainPosts.orders + * @param {RawPost[]} chainPosts.rawPosts + * @param {string} chainPosts.previousPostId + * @returns {RawPost[]} + */ +export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainPostsArgs) => { + const posts: RecordPair[] = []; + + rawPosts.forEach((post) => { + const postId = post.id; + const orderIndex = orders.findIndex((order) => { + return order === postId; + }); + + if (orderIndex === -1) { + // This case will not occur as we are using 'ordered' posts for this step. However, if this happens, that + // implies that we might be dealing with an unordered post and in which case we do not action on it. + } else if (orderIndex === 0) { + posts.push({record: undefined, raw: {...post, prev_post_id: previousPostId}}); + } else { + posts.push({record: undefined, raw: {...post, prev_post_id: orders[orderIndex - 1]}}); + } + }); + + return posts; +}; diff --git a/app/database/operator/utils/reaction.ts b/app/database/operator/utils/reaction.ts new file mode 100644 index 0000000000..19645bf15a --- /dev/null +++ b/app/database/operator/utils/reaction.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; +import {RecordPair, SanitizeReactionsArgs} from '@typings/database/database'; +import Reaction from '@typings/database/reaction'; + +const {REACTION} = MM_TABLES.SERVER; + +/** + * sanitizeReactions: Treats reactions happening on a Post. For example, a user can add/remove an emoji. Hence, this function + * tell us which reactions to create/delete in the Reaction table and which custom-emoji to create in our database. + * For more information, please have a look at https://community.mattermost.com/core/pl/rq9e8jnonpyrmnyxpuzyc4d6ko + * @param {SanitizeReactionsArgs} sanitizeReactions + * @param {Database} sanitizeReactions.database + * @param {string} sanitizeReactions.post_id + * @param {RawReaction[]} sanitizeReactions.rawReactions + * @returns {Promise<{createReactions: RawReaction[], createEmojis: {name: string}[], deleteReactions: Reaction[]}>} + */ +export const sanitizeReactions = async ({database, post_id, rawReactions}: SanitizeReactionsArgs) => { + const reactions = (await database.collections. + get(REACTION). + query(Q.where('post_id', post_id)). + fetch()) as Reaction[]; + + // similarObjects: Contains objects that are in both the RawReaction array and in the Reaction entity + const similarObjects: Reaction[] = []; + + const createReactions: RecordPair[] = []; + + const emojiSet = new Set(); + + for (let i = 0; i < rawReactions.length; i++) { + const rawReaction = rawReactions[i]; + + // Do we have a similar value of rawReaction in the REACTION table? + const idxPresent = reactions.findIndex((value) => { + return ( + value.userId === rawReaction.user_id && + value.emojiName === rawReaction.emoji_name + ); + }); + + if (idxPresent === -1) { + // So, we don't have a similar Reaction object. That one is new...so we'll create it + createReactions.push({record: undefined, raw: rawReaction}); + + // If that reaction is new, that implies that the emoji might also be new + emojiSet.add(rawReaction.emoji_name); + } else { + // we have a similar object in both reactions and rawReactions; we'll pop it out from both arrays + similarObjects.push(reactions[idxPresent]); + } + } + + // finding out elements to delete using array subtract + const deleteReactions = reactions. + filter((reaction) => !similarObjects.includes(reaction)). + map((outCast) => outCast.prepareDestroyPermanently()); + + const createEmojis = Array.from(emojiSet).map((emoji) => { + return {name: emoji}; + }); + + return {createReactions, createEmojis, deleteReactions}; +}; diff --git a/app/database/admin/data_operator/utils/test.ts b/app/database/operator/utils/test.ts similarity index 92% rename from app/database/admin/data_operator/utils/test.ts rename to app/database/operator/utils/test.ts index ed34ec94d6..e34189347c 100644 --- a/app/database/admin/data_operator/utils/test.ts +++ b/app/database/operator/utils/test.ts @@ -1,15 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import DataOperator from '@database/admin/data_operator'; -import DatabaseManager from '@database/admin/database_manager'; +import {DataOperator} from '@database/operator'; +import {createPostsChain, sanitizePosts} from '@database/operator/utils/post'; +import {sanitizeReactions} from '@database/operator/utils/reaction'; +import DatabaseManager from '@database/manager'; import {DatabaseType} from '@typings/database/enums'; import {RawPost} from '@typings/database/database'; +import Reaction from '@typings/database/reaction'; -import {createPostsChain, sanitizePosts, sanitizeReactions} from './index'; import {mockedPosts, mockedReactions} from './mock'; -jest.mock('@database/admin/database_manager'); +jest.mock('@database/manager'); describe('DataOperator: Utils tests', () => { it('=> sanitizePosts: should filter between ordered and unordered posts', () => { @@ -95,8 +97,8 @@ describe('DataOperator: Utils tests', () => { delete_at: 0, }, ], - prepareRowsOnly: true, - }); + prepareRecordsOnly: true, + }) as Reaction[]; // Jest in not using the same database instance amongst the Singletons; hence, we are creating the reaction record here // eslint-disable-next-line max-nested-callbacks diff --git a/app/database/admin/data_operator/wrapper/index.ts b/app/database/operator/wrapper/index.ts similarity index 63% rename from app/database/admin/data_operator/wrapper/index.ts rename to app/database/operator/wrapper/index.ts index 7e51ac9f5b..97fb68931f 100644 --- a/app/database/admin/data_operator/wrapper/index.ts +++ b/app/database/operator/wrapper/index.ts @@ -3,9 +3,9 @@ import {Database} from '@nozbe/watermelondb'; -import DataOperator from '@database/admin/data_operator/handlers'; -import DatabaseManager from '@database/admin/database_manager'; -import DatabaseConnectionException from '@database/admin/exceptions/database_connection_exception'; +import {Operator} from '@database/operator/index'; +import DatabaseManager from '@database/manager'; +import DatabaseConnectionException from '@database/exceptions/database_connection_exception'; export const createDataOperator = async (serverUrl: string) => { // Retrieves the connection matching serverUrl @@ -14,9 +14,9 @@ export const createDataOperator = async (serverUrl: string) => { ]); if (connections?.length) { - // finds the connection that corresponds to the serverUrl value - const index = connections.findIndex((connection) => { - return connection.url === serverUrl; + // finds the connection that corresponds to the serverUrl value + const index = connections.findIndex((databaseInstance) => { + return databaseInstance.url === serverUrl; }); if (!connections?.[index]?.dbInstance) { @@ -27,7 +27,10 @@ export const createDataOperator = async (serverUrl: string) => { const connection = connections[index].dbInstance as Database; - return new DataOperator(connection); + const operator = new Operator(); + operator.setActiveDatabase(connection); + + return operator; } throw new DatabaseConnectionException( diff --git a/app/database/admin/data_operator/wrapper/test.ts b/app/database/operator/wrapper/test.ts similarity index 97% rename from app/database/admin/data_operator/wrapper/test.ts rename to app/database/operator/wrapper/test.ts index c36197857f..7aaff64513 100644 --- a/app/database/admin/data_operator/wrapper/test.ts +++ b/app/database/operator/wrapper/test.ts @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import DatabaseManager from '@database/admin/database_manager'; -import {createDataOperator} from '@database/admin/data_operator/wrapper'; -import DatabaseConnectionException from '@database/admin/exceptions/database_connection_exception'; +import DatabaseManager from '@database/manager'; +import {createDataOperator} from '@database/operator/wrapper/index'; +import DatabaseConnectionException from '@database/exceptions/database_connection_exception'; import {DatabaseType} from '@typings/database/enums'; -jest.mock('@database/admin/database_manager'); +jest.mock('@database/manager'); /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -253,7 +253,7 @@ describe('*** DataOperator Wrapper ***', () => { delete_at: 0, }, ], - prepareRowsOnly: true, + prepareRecordsOnly: true, }); expect(spyOnHandleFiles).toHaveBeenCalledTimes(1); @@ -277,7 +277,7 @@ describe('*** DataOperator Wrapper ***', () => { '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', }, ], - prepareRowsOnly: true, + prepareRecordsOnly: true, }); expect(spyOnHandlePostMetadata).toHaveBeenCalledTimes(1); @@ -327,12 +327,13 @@ describe('*** DataOperator Wrapper ***', () => { postId: '8swgtrrdiff89jnsiwiip3y1eoe', }, ], - prepareRowsOnly: true, + prepareRecordsOnly: true, }); expect(spyOnHandleCustomEmojis).toHaveBeenCalledTimes(1); expect(spyOnHandleCustomEmojis).toHaveBeenCalledWith({ tableName: 'CustomEmoji', + prepareRecordsOnly: false, values: [ { id: 'dgwyadacdbbwjc8t357h6hwsrh', diff --git a/app/database/default/schema/index.ts b/app/database/schema/default/index.ts similarity index 100% rename from app/database/default/schema/index.ts rename to app/database/schema/default/index.ts diff --git a/app/database/default/schema/table_schemas/app.ts b/app/database/schema/default/table_schemas/app.ts similarity index 100% rename from app/database/default/schema/table_schemas/app.ts rename to app/database/schema/default/table_schemas/app.ts diff --git a/app/database/default/schema/table_schemas/global.ts b/app/database/schema/default/table_schemas/global.ts similarity index 100% rename from app/database/default/schema/table_schemas/global.ts rename to app/database/schema/default/table_schemas/global.ts diff --git a/app/database/default/schema/table_schemas/index.ts b/app/database/schema/default/table_schemas/index.ts similarity index 100% rename from app/database/default/schema/table_schemas/index.ts rename to app/database/schema/default/table_schemas/index.ts diff --git a/app/database/default/schema/table_schemas/servers.ts b/app/database/schema/default/table_schemas/servers.ts similarity index 100% rename from app/database/default/schema/table_schemas/servers.ts rename to app/database/schema/default/table_schemas/servers.ts diff --git a/app/database/default/schema/test.ts b/app/database/schema/default/test.ts similarity index 100% rename from app/database/default/schema/test.ts rename to app/database/schema/default/test.ts diff --git a/app/database/server/schema/index.ts b/app/database/schema/server/index.ts similarity index 100% rename from app/database/server/schema/index.ts rename to app/database/schema/server/index.ts diff --git a/app/database/server/schema/table_schemas/channel.ts b/app/database/schema/server/table_schemas/channel.ts similarity index 100% rename from app/database/server/schema/table_schemas/channel.ts rename to app/database/schema/server/table_schemas/channel.ts diff --git a/app/database/server/schema/table_schemas/channel_info.ts b/app/database/schema/server/table_schemas/channel_info.ts similarity index 100% rename from app/database/server/schema/table_schemas/channel_info.ts rename to app/database/schema/server/table_schemas/channel_info.ts diff --git a/app/database/server/schema/table_schemas/channel_membership.ts b/app/database/schema/server/table_schemas/channel_membership.ts similarity index 100% rename from app/database/server/schema/table_schemas/channel_membership.ts rename to app/database/schema/server/table_schemas/channel_membership.ts diff --git a/app/database/server/schema/table_schemas/custom_emoji.ts b/app/database/schema/server/table_schemas/custom_emoji.ts similarity index 100% rename from app/database/server/schema/table_schemas/custom_emoji.ts rename to app/database/schema/server/table_schemas/custom_emoji.ts diff --git a/app/database/server/schema/table_schemas/draft.ts b/app/database/schema/server/table_schemas/draft.ts similarity index 100% rename from app/database/server/schema/table_schemas/draft.ts rename to app/database/schema/server/table_schemas/draft.ts diff --git a/app/database/server/schema/table_schemas/file.ts b/app/database/schema/server/table_schemas/file.ts similarity index 100% rename from app/database/server/schema/table_schemas/file.ts rename to app/database/schema/server/table_schemas/file.ts diff --git a/app/database/server/schema/table_schemas/group.ts b/app/database/schema/server/table_schemas/group.ts similarity index 100% rename from app/database/server/schema/table_schemas/group.ts rename to app/database/schema/server/table_schemas/group.ts diff --git a/app/database/server/schema/table_schemas/group_membership.ts b/app/database/schema/server/table_schemas/group_membership.ts similarity index 100% rename from app/database/server/schema/table_schemas/group_membership.ts rename to app/database/schema/server/table_schemas/group_membership.ts diff --git a/app/database/server/schema/table_schemas/groups_in_channel.ts b/app/database/schema/server/table_schemas/groups_in_channel.ts similarity index 100% rename from app/database/server/schema/table_schemas/groups_in_channel.ts rename to app/database/schema/server/table_schemas/groups_in_channel.ts diff --git a/app/database/server/schema/table_schemas/groups_in_team.ts b/app/database/schema/server/table_schemas/groups_in_team.ts similarity index 82% rename from app/database/server/schema/table_schemas/groups_in_team.ts rename to app/database/schema/server/table_schemas/groups_in_team.ts index b25f576760..9c48d64331 100644 --- a/app/database/server/schema/table_schemas/groups_in_team.ts +++ b/app/database/schema/server/table_schemas/groups_in_team.ts @@ -11,8 +11,6 @@ export default tableSchema({ name: GROUPS_IN_TEAM, columns: [ {name: 'group_id', type: 'string', isIndexed: true}, - {name: 'member_count', type: 'number'}, {name: 'team_id', type: 'string', isIndexed: true}, - {name: 'timezone_count', type: 'number'}, ], }); diff --git a/app/database/server/schema/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts similarity index 100% rename from app/database/server/schema/table_schemas/index.ts rename to app/database/schema/server/table_schemas/index.ts diff --git a/app/database/server/schema/table_schemas/my_channel.ts b/app/database/schema/server/table_schemas/my_channel.ts similarity index 100% rename from app/database/server/schema/table_schemas/my_channel.ts rename to app/database/schema/server/table_schemas/my_channel.ts diff --git a/app/database/server/schema/table_schemas/my_channel_settings.ts b/app/database/schema/server/table_schemas/my_channel_settings.ts similarity index 100% rename from app/database/server/schema/table_schemas/my_channel_settings.ts rename to app/database/schema/server/table_schemas/my_channel_settings.ts diff --git a/app/database/server/schema/table_schemas/my_team.ts b/app/database/schema/server/table_schemas/my_team.ts similarity index 100% rename from app/database/server/schema/table_schemas/my_team.ts rename to app/database/schema/server/table_schemas/my_team.ts diff --git a/app/database/server/schema/table_schemas/post.ts b/app/database/schema/server/table_schemas/post.ts similarity index 100% rename from app/database/server/schema/table_schemas/post.ts rename to app/database/schema/server/table_schemas/post.ts diff --git a/app/database/server/schema/table_schemas/post_metadata.ts b/app/database/schema/server/table_schemas/post_metadata.ts similarity index 100% rename from app/database/server/schema/table_schemas/post_metadata.ts rename to app/database/schema/server/table_schemas/post_metadata.ts diff --git a/app/database/server/schema/table_schemas/posts_in_channel.ts b/app/database/schema/server/table_schemas/posts_in_channel.ts similarity index 100% rename from app/database/server/schema/table_schemas/posts_in_channel.ts rename to app/database/schema/server/table_schemas/posts_in_channel.ts diff --git a/app/database/server/schema/table_schemas/posts_in_thread.ts b/app/database/schema/server/table_schemas/posts_in_thread.ts similarity index 100% rename from app/database/server/schema/table_schemas/posts_in_thread.ts rename to app/database/schema/server/table_schemas/posts_in_thread.ts diff --git a/app/database/server/schema/table_schemas/preference.ts b/app/database/schema/server/table_schemas/preference.ts similarity index 100% rename from app/database/server/schema/table_schemas/preference.ts rename to app/database/schema/server/table_schemas/preference.ts diff --git a/app/database/server/schema/table_schemas/reaction.ts b/app/database/schema/server/table_schemas/reaction.ts similarity index 100% rename from app/database/server/schema/table_schemas/reaction.ts rename to app/database/schema/server/table_schemas/reaction.ts diff --git a/app/database/server/schema/table_schemas/role.ts b/app/database/schema/server/table_schemas/role.ts similarity index 100% rename from app/database/server/schema/table_schemas/role.ts rename to app/database/schema/server/table_schemas/role.ts diff --git a/app/database/server/schema/table_schemas/slash_command.ts b/app/database/schema/server/table_schemas/slash_command.ts similarity index 100% rename from app/database/server/schema/table_schemas/slash_command.ts rename to app/database/schema/server/table_schemas/slash_command.ts diff --git a/app/database/server/schema/table_schemas/system.ts b/app/database/schema/server/table_schemas/system.ts similarity index 100% rename from app/database/server/schema/table_schemas/system.ts rename to app/database/schema/server/table_schemas/system.ts diff --git a/app/database/server/schema/table_schemas/team.ts b/app/database/schema/server/table_schemas/team.ts similarity index 100% rename from app/database/server/schema/table_schemas/team.ts rename to app/database/schema/server/table_schemas/team.ts diff --git a/app/database/server/schema/table_schemas/team_channel_history.ts b/app/database/schema/server/table_schemas/team_channel_history.ts similarity index 100% rename from app/database/server/schema/table_schemas/team_channel_history.ts rename to app/database/schema/server/table_schemas/team_channel_history.ts diff --git a/app/database/server/schema/table_schemas/team_membership.ts b/app/database/schema/server/table_schemas/team_membership.ts similarity index 100% rename from app/database/server/schema/table_schemas/team_membership.ts rename to app/database/schema/server/table_schemas/team_membership.ts diff --git a/app/database/server/schema/table_schemas/team_search_history.ts b/app/database/schema/server/table_schemas/team_search_history.ts similarity index 100% rename from app/database/server/schema/table_schemas/team_search_history.ts rename to app/database/schema/server/table_schemas/team_search_history.ts diff --git a/app/database/server/schema/table_schemas/terms_of_service.ts b/app/database/schema/server/table_schemas/terms_of_service.ts similarity index 100% rename from app/database/server/schema/table_schemas/terms_of_service.ts rename to app/database/schema/server/table_schemas/terms_of_service.ts diff --git a/app/database/server/schema/table_schemas/user.ts b/app/database/schema/server/table_schemas/user.ts similarity index 100% rename from app/database/server/schema/table_schemas/user.ts rename to app/database/schema/server/table_schemas/user.ts diff --git a/app/database/server/schema/test.ts b/app/database/schema/server/test.ts similarity index 98% rename from app/database/server/schema/test.ts rename to app/database/schema/server/test.ts index 09e37895b7..9bd46c43fd 100644 --- a/app/database/server/schema/test.ts +++ b/app/database/schema/server/test.ts @@ -281,15 +281,11 @@ describe('*** Test schema for SERVER database ***', () => { name: GROUPS_IN_TEAM, columns: { group_id: {name: 'group_id', type: 'string', isIndexed: true}, - member_count: {name: 'member_count', type: 'number'}, team_id: {name: 'team_id', type: 'string', isIndexed: true}, - timezone_count: {name: 'timezone_count', type: 'number'}, }, columnArray: [ {name: 'group_id', type: 'string', isIndexed: true}, - {name: 'member_count', type: 'number'}, {name: 'team_id', type: 'string', isIndexed: true}, - {name: 'timezone_count', type: 'number'}, ], }, [GROUP_MEMBERSHIP]: { diff --git a/app/utils/mix.ts b/app/utils/mix.ts new file mode 100644 index 0000000000..4d89a7f1b6 --- /dev/null +++ b/app/utils/mix.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +class MixinBuilder { + superclass: any; + constructor(superclass: any) { + this.superclass = superclass; + } + + with(...mixins: any[]) { + return mixins.reduce((c, mixin) => mixin(c), this.superclass); + } +} + +const mix = (superclass: any) => new MixinBuilder(superclass); + +export default mix; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8300d375bd..65f119c4be 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -711,7 +711,7 @@ SPEC CHECKSUMS: EXConstants: c00cd53a17a65b2e53ddb3890e4e74d3418e406e EXFileSystem: 35769beb727d5341d1276fd222710f9704f7164e FBLazyVector: 49cbe4b43e445b06bf29199b6ad2057649e4c8f5 - FBReactNativeSpec: a804c9d6c798f94831713302354003ee54ea18cb + FBReactNativeSpec: ebaa990b13e6f0496fd41894a824c585c4afab46 glog: 73c2498ac6884b13ede40eda8228cb1eee9d9d62 jail-monkey: 80c9e34da2cd54023e5ad08bf7051ec75bd43d5b libwebp: 946cb3063cea9236285f7e9a8505d806d30e07f3 diff --git a/package-lock.json b/package-lock.json index fd72becabe..426190362b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3277,13 +3277,6 @@ "rambdax": "2.15.0", "rxjs": "^6.5.3", "sql-escape-string": "^1.1.0" - }, - "dependencies": { - "lokijs": { - "version": "npm:@nozbe/lokijs@1.5.12-wmelon", - "resolved": "https://registry.npmjs.org/@nozbe/lokijs/-/lokijs-1.5.12-wmelon.tgz", - "integrity": "sha512-7xQUn80pzPBB9VcwvB/W2V9/60xIfuk+3IDIvS9cU7W29jJx4QBXe5dBWTaARmxD9hXozPCcPWh2wfd7m4dbTA==" - } } }, "@nozbe/with-observables": { @@ -16430,6 +16423,11 @@ } } }, + "lokijs": { + "version": "npm:@nozbe/lokijs@1.5.12-wmelon", + "resolved": "https://registry.npmjs.org/@nozbe/lokijs/-/lokijs-1.5.12-wmelon.tgz", + "integrity": "sha512-7xQUn80pzPBB9VcwvB/W2V9/60xIfuk+3IDIvS9cU7W29jJx4QBXe5dBWTaARmxD9hXozPCcPWh2wfd7m4dbTA==" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/patches/@nozbe+watermelondb+0.21.0.patch b/patches/@nozbe+watermelondb+0.21.0.patch index 801133c19d..eea406d0c3 100644 --- a/patches/@nozbe+watermelondb+0.21.0.patch +++ b/patches/@nozbe+watermelondb+0.21.0.patch @@ -23,13 +23,15 @@ index 380f9c7..4302827 100644 export default class SQLiteAdapter implements DatabaseAdapter { diff --git a/node_modules/@nozbe/watermelondb/adapters/sqlite/index.js b/node_modules/@nozbe/watermelondb/adapters/sqlite/index.js -index 54fe949..f7d6acf 100644 +index 54fe949..f32d308 100644 --- a/node_modules/@nozbe/watermelondb/adapters/sqlite/index.js +++ b/node_modules/@nozbe/watermelondb/adapters/sqlite/index.js -@@ -36,9 +36,11 @@ function () { +@@ -35,10 +35,12 @@ function () { + var { dbName: dbName, schema: schema, - migrations: migrations +- migrations: migrations ++ migrations: migrations, + migrationEvents: migrationEvents } = options; this.schema = schema; diff --git a/types/database/database.d.ts b/types/database/database.d.ts index adc8b1b615..3bc927c872 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -120,6 +120,7 @@ export type RawFile = { }; export type RawReaction = { + id? : string; create_at: number; delete_at: number; emoji_name: string; @@ -233,6 +234,7 @@ export type RawUser = { }; export type RawPreference = { + id? : string; category: string; name: string; user_id: string; @@ -240,6 +242,7 @@ export type RawPreference = { }; export type RawTeamMembership = { + id? : string; delete_at: number; explicit_roles: string; roles: string; @@ -251,11 +254,13 @@ export type RawTeamMembership = { }; export type RawGroupMembership = { + id?: string; user_id: string; group_id: string; }; export type RawChannelMembership = { + id? : string; channel_id: string; user_id: string; roles: string; @@ -333,6 +338,8 @@ export type RawGroupsInChannel = { team_id: string; team_type: string; update_at: number; + member_count: number; + timezone_count: number; }; export type RawTeam = { @@ -355,16 +362,16 @@ export type RawTeam = { }; export type RawTeamChannelHistory = { -team_id: string; -channel_ids: string[] -} + team_id: string; + channel_ids: string[]; +}; export type RawTeamSearchHistory = { created_at: number; display_term: string; term: string; team_id: string; -} +}; export type RawSlashCommand = { id: string; @@ -417,9 +424,9 @@ export type RawChannel = { }; export type RawMyChannelSettings = { - notify_props: NotifyProps, + notify_props: NotifyProps; channel_id: string; -} +}; export type RawChannelInfo = { channel_id: string; @@ -428,7 +435,7 @@ export type RawChannelInfo = { member_count: number; pinned_post_count: number; purpose: string; -} +}; export type RawMyChannel = { channel_id: string; @@ -437,7 +444,7 @@ export type RawMyChannel = { mentions_count: number; message_count: number; roles: string; -} +}; export type RawValue = | RawApp @@ -472,8 +479,6 @@ export type RawValue = | RawTermsOfService | RawUser; -export type MatchExistingRecord = { record?: Model; raw: RawValue }; - export type DataFactoryArgs = { action: string; database: Database; @@ -486,7 +491,7 @@ export type PrepareForDatabaseArgs = { tableName: string; createRaws?: MatchExistingRecord[]; updateRaws?: MatchExistingRecord[]; - recordOperator: (dataArgs: DataFactoryArgs) => void; + recordOperator: (DataFactoryArgs) => Promise; }; export type PrepareRecordsArgs = PrepareForDatabaseArgs & { @@ -498,6 +503,7 @@ export type BatchOperationsArgs = { database: Database; models: Model[] }; export type HandleIsolatedEntityArgs = { tableName: IsolatedEntities; values: RawValue[]; + prepareRecordsOnly: boolean; }; export type Models = Class[]; @@ -515,19 +521,19 @@ export type ActiveServerDatabaseArgs = { }; export type HandleReactionsArgs = { - prepareRowsOnly: boolean; + prepareRecordsOnly: boolean; reactions: RawReaction[]; }; export type HandleFilesArgs = { files: RawFile[]; - prepareRowsOnly: boolean; + prepareRecordsOnly: boolean; }; export type HandlePostMetadataArgs = { embeds?: { embed: RawEmbed[]; postId: string }[]; images?: { images: Dictionary; postId: string }[]; - prepareRowsOnly: boolean; + prepareRecordsOnly: boolean; }; export type HandlePostsArgs = { @@ -575,9 +581,10 @@ export type ProcessInputsArgs = { export type HandleEntityRecordsArgs = { findMatchingRecordBy: (existing: Model, newElement: RawValue) => boolean; fieldName: string; - operator: (dataArgs: DataFactoryArgs) => Promise; + operator: (DataFactoryArgs) => Promise; rawValues: RawValue[]; tableName: string; + prepareRecordsOnly: boolean; }; export type DatabaseInstances = { @@ -594,3 +601,87 @@ export type RecordPair = { record?: Model; raw: RawValue; }; + +export type HandleMyChannelArgs = { + myChannels: RawMyChannel[]; + prepareRecordsOnly: boolean; +}; + +export type HandleChannelInfoArgs = { + channelInfos: RawChannelInfo[]; + prepareRecordsOnly: boolean; +}; + +export type HandleMyChannelSettingsArgs = { + settings: RawMyChannelSettings[]; + prepareRecordsOnly: boolean; +}; + +export type HandleChannelArgs = { + channels: RawChannel[]; + prepareRecordsOnly: boolean; +}; + +export type HandleMyTeamArgs = { + myTeams: RawMyTeam[]; + prepareRecordsOnly: boolean; +}; + +export type HandleSlashCommandArgs = { + slashCommands: RawSlashCommand[]; + prepareRecordsOnly: boolean; +}; + +export type HandleTeamSearchHistoryArgs = { + teamSearchHistories: RawTeamSearchHistory[]; + prepareRecordsOnly: boolean; +}; + +export type HandleTeamChannelHistoryArgs = { + teamChannelHistories: RawTeamChannelHistory[]; + prepareRecordsOnly: boolean; +}; + +export type HandleTeamArgs = { teams: RawTeam[]; prepareRecordsOnly: boolean }; + +export type HandleGroupsInChannelArgs = { + groupsInChannels: RawGroupsInChannel[]; + prepareRecordsOnly: boolean; +}; + +export type HandleGroupsInTeamArgs = { + groupsInTeams: RawGroupsInTeam[]; + prepareRecordsOnly: boolean; +}; + +export type HandleGroupArgs = { + groups: RawGroup[]; + prepareRecordsOnly: boolean; +}; + +export type HandleChannelMembershipArgs = { + channelMemberships: RawChannelMembership[]; + prepareRecordsOnly: boolean; +}; + +export type HandleGroupMembershipArgs = { + groupMemberships: RawGroupMembership[]; + prepareRecordsOnly: boolean; +}; + +export type HandleTeamMembershipArgs = { + teamMemberships: RawTeamMembership[]; + prepareRecordsOnly: boolean; +}; + +export type HandlePreferencesArgs = { + preferences: RawPreference[]; + prepareRecordsOnly: boolean; +}; + +export type HandleUsersArgs = { users: RawUser[]; prepareRecordsOnly: boolean }; + +export type HandleDraftArgs = { + drafts: RawDraft[]; + prepareRecordsOnly: boolean; +};