[Gekidou MM-39707] CRT DB (#5948)

* Database init

* Naming fix

* naming misc

* Fix test

* Added Thread Tab columns, Team Threads Count table and other changes

* Test case fix

* Test cases fix ...... AGAIN

* TS fix

* Removed loaded_in_all_threads_tab, loaded_in_unreads_tab

* Removed TeamThreadsCount table, mention & message root counts & added loadedInGlobalThreads flag

* Type changes, added delete thread with post

* Removed unused type

* Reverted relationshio of post with thread

* Calling thread destroyPermanently from post

* Removed unused table name variables

* added THREAD constant table in post model and fixed a few comments

* Misc typo fix and code clean up

* Added test case and related to participant in user model

* test cases fix

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Anurag Shivarathri
2022-03-03 22:47:29 +05:30
committed by GitHub
parent e93c570562
commit 9dbdae22fd
32 changed files with 717 additions and 14 deletions

View File

@@ -36,7 +36,6 @@ export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: Use
pending_post_id: '',
reply_count: 0,
metadata: {},
participants: null,
root_id: postRootId,
props: {
username: user.username,

View File

@@ -34,6 +34,8 @@ export const MM_TABLES = {
TEAM_MEMBERSHIP: 'TeamMembership',
TEAM_SEARCH_HISTORY: 'TeamSearchHistory',
TERMS_OF_SERVICE: 'TermsOfService',
THREAD: 'Thread',
THREAD_PARTICIPANT: 'ThreadParticipant',
USER: 'User',
},
};

View File

@@ -15,7 +15,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha
MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
TermsOfServiceModel, UserModel,
TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
@@ -51,7 +51,7 @@ class DatabaseManager {
MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
TermsOfServiceModel, UserModel,
TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel,
];
this.databaseDirectory = '';
}

View File

@@ -16,7 +16,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha
MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
TermsOfServiceModel, UserModel,
TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
@@ -46,7 +46,7 @@ class DatabaseManager {
MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
TermsOfServiceModel, UserModel,
TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel,
];
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${FileSystem.documentDirectory}databases/`;

View File

@@ -25,4 +25,6 @@ export {default as TeamMembershipModel} from './team_membership';
export {default as TeamModel} from './team';
export {default as TeamSearchHistoryModel} from './team_search_history';
export {default as TermsOfServiceModel} from './terms_of_service';
export {default as ThreadModel} from './thread';
export {default as ThreadParticipantModel} from './thread_participant';
export {default as UserModel} from './user';

View File

@@ -13,9 +13,10 @@ import type DraftModel from '@typings/database/models/servers/draft';
import type FileModel from '@typings/database/models/servers/file';
import type PostInThreadModel from '@typings/database/models/servers/posts_in_thread';
import type ReactionModel from '@typings/database/models/servers/reaction';
import type ThreadModel from '@typings/database/models/servers/thread';
import type UserModel from '@typings/database/models/servers/user';
const {CHANNEL, DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, USER} = MM_TABLES.SERVER;
const {CHANNEL, DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, THREAD, USER} = MM_TABLES.SERVER;
/**
* The Post model is the building block of communication in the Mattermost app.
@@ -42,6 +43,9 @@ export default class PostModel extends Model {
/** A POST can have multiple REACTION. (relationship is 1:N)*/
[REACTION]: {type: 'has_many', foreignKey: 'post_id'},
/** A POST can have an associated thread. (relationship is 1:1) */
[THREAD]: {type: 'has_many', foreignKey: 'id'},
/** A USER can have multiple POST. A user can author several posts. (relationship is 1:N)*/
[USER]: {type: 'belongs_to', key: 'user_id'},
};
@@ -115,6 +119,9 @@ export default class PostModel extends Model {
/** channel: The channel which is presenting this Post */
@immutableRelation(CHANNEL, 'channel_id') channel!: Relation<ChannelModel>;
/** thread : The thread data for the post */
@immutableRelation(THREAD, 'id') thread!: Relation<ThreadModel>;
async destroyPermanently() {
await this.reactions.destroyAllPermanently();
await this.files.destroyAllPermanently();
@@ -122,6 +129,11 @@ export default class PostModel extends Model {
await this.collections.get(POSTS_IN_THREAD).query(
Q.where('root_id', this.id),
).destroyAllPermanently();
try {
(await this.thread.fetch())?.destroyPermanently();
} catch {
// there is no thread record for this post
}
super.destroyPermanently();
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Query, Relation} from '@nozbe/watermelondb';
import {children, field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import type PostModel from '@typings/database/models/servers/post';
import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant';
const {POST, THREAD, THREAD_PARTICIPANT} = MM_TABLES.SERVER;
/**
* The Thread model contains thread information of a post.
*/
export default class ThreadModel extends Model {
/** table (name) : Thread */
static table = THREAD;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A THREAD is associated to one POST (relationship is 1:1) */
[POST]: {type: 'belongs_to', key: 'id'},
/** A THREAD can have multiple THREAD_PARTICIPANT. (relationship is 1:N)*/
[THREAD_PARTICIPANT]: {type: 'has_many', foreignKey: 'thread_id'},
};
/** last_reply_at : The timestamp of when user last replied to the thread. */
@field('last_reply_at') lastReplyAt!: number;
/** last_viewed_at : The timestamp of when user last viewed the thread. */
@field('last_viewed_at') lastViewedAt!: number;
/** reply_count : The total replies to the thread by all the participants. */
@field('reply_count') replyCount!: number;
/** is_following: If user is following the thread or not */
@field('is_following') isFollowing!: boolean;
/** unread_replies : The number of replies that have not been read by the user. */
@field('unread_replies') unreadReplies!: number;
/** unread_mentions : The number of mentions that have not been read by the user. */
@field('unread_mentions') unreadMentions!: number;
/** loaded_in_global_threads : Flag to differentiate the unread threads loaded for showing unread counts/mentions */
@field('loaded_in_global_threads') loadedInGlobalThreads!: boolean;
/** participants : All the participants associated with this Thread */
@children(THREAD_PARTICIPANT) participants!: Query<ThreadParticipantModel>;
/** post : The root post of this thread */
@immutableRelation(POST, 'id') post!: Relation<PostModel>;
async destroyPermanently() {
await this.participants.destroyAllPermanently();
super.destroyPermanently();
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import {field, immutableRelation} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import type ThreadModel from '@typings/database/models/servers/thread';
import type UserModel from '@typings/database/models/servers/user';
const {THREAD, THREAD_PARTICIPANT, USER} = MM_TABLES.SERVER;
/**
* The Thread Participants model contains participants data of a thread.
*/
export default class ThreadParticipantModel extends Model {
/** table (name) : ThreadParticipant */
static table = THREAD_PARTICIPANT;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A THREAD can have multiple PARTICIPANTS. (relationship is 1:N) */
[THREAD]: {type: 'belongs_to', key: 'thread_id'},
/** A USER can participate in multiple THREADS. (relationship is 1:N) */
[USER]: {type: 'belongs_to', key: 'user_id'},
};
/** thread_id : thread id to which participant belong to. */
@field('thread_id') threadId!: string;
/** user_id : user id of the participant. */
@field('user_id') userId!: number;
/** thread : The related record of the Thread model */
@immutableRelation(THREAD, 'thread_id') thread!: Relation<ThreadModel>;
/** user : The related record of the User model */
@immutableRelation(USER, 'user_id') user!: Relation<UserModel>;
}

View File

@@ -5,6 +5,7 @@ import {children, field, json} from '@nozbe/watermelondb/decorators';
import Model, {Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import ThreadParticipantsModel from '@typings/database/models/servers/thread_participant';
import {safeParseJSON} from '@utils/helpers';
import type ChannelModel from '@typings/database/models/servers/channel';
@@ -22,6 +23,7 @@ const {
PREFERENCE,
REACTION,
TEAM_MEMBERSHIP,
THREAD_PARTICIPANT,
USER,
} = MM_TABLES.SERVER;
@@ -53,6 +55,9 @@ export default class UserModel extends Model {
/** USER has a 1:N relationship with TEAM_MEMBERSHIP. A user can join multiple teams */
[TEAM_MEMBERSHIP]: {type: 'has_many', foreignKey: 'user_id'},
/** USER has a 1:N relationship with THREAD_PARTICIPANT. A user can participante in multiple threads */
[THREAD_PARTICIPANT]: {type: 'has_many', foreignKey: 'user_id'},
};
/** auth_service : The type of authentication service registered to that user */
@@ -129,6 +134,9 @@ export default class UserModel extends Model {
/** teams : All the team that this user is part of */
@children(TEAM_MEMBERSHIP) teams!: TeamMembershipModel[];
/** threadParticipations : All the thread participations this user is part of */
@children(THREAD_PARTICIPANT) threadParticipations!: ThreadParticipantsModel[];
prepareStatus = (status: string) => {
this.prepareUpdate((u) => {
u.status = status;

View File

@@ -22,6 +22,7 @@ import type TeamChannelHistoryModel from '@typings/database/models/servers/team_
import type TeamMembershipModel from '@typings/database/models/servers/team_membership';
import type TeamSearchHistoryModel from '@typings/database/models/servers/team_search_history';
import type TermsOfServiceModel from '@typings/database/models/servers/terms_of_service';
import type ThreadModel from '@typings/database/models/servers/thread';
import type UserModel from '@typings/database/models/servers/user';
/**
@@ -122,3 +123,7 @@ export const isRecordMyChannelEqualToRaw = (record: MyChannelModel, raw: Channel
export const isRecordFileEqualToRaw = (record: FileModel, raw: FileInfo) => {
return raw.id === record.id;
};
export const isRecordThreadEqualToRaw = (record: ThreadModel, raw: Thread) => {
return raw.id === record.id;
};

View File

@@ -75,6 +75,7 @@ describe('*** Operator: Post Handlers tests ***', () => {
edit_at: 0,
delete_at: 0,
is_pinned: false,
is_following: false,
user_id: 'q3mzxua9zjfczqakxdkowc6u6yy',
channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw',
root_id: '',
@@ -169,6 +170,7 @@ describe('*** Operator: Post Handlers tests ***', () => {
edit_at: 0,
delete_at: 0,
is_pinned: false,
is_following: false,
user_id: 'hy5sq51sebfh58ktrce5ijtcwyy',
channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw',
root_id: '8swgtrrdiff89jnsiwiip3y1eoe',
@@ -195,6 +197,7 @@ describe('*** Operator: Post Handlers tests ***', () => {
edit_at: 0,
delete_at: 0,
is_pinned: false,
is_following: false,
user_id: '44ud4m9tqwby3mphzzdwm7h31sr',
channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw',
root_id: '8swgtrrdiff89jnsiwiip3y1eoe',

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {isRecordThreadEqualToRaw} from '@database/operator/server_data_operator/comparators';
import {transformThreadRecord, transformThreadParticipantRecord} from '@database/operator/server_data_operator/transformers/thread';
import ServerDataOperator from '..';
jest.mock('@database/operator/utils/thread', () => {
return {
sanitizeThreadParticipants: ({rawParticipants}: {rawParticipants: ThreadParticipant[]}) => {
return {
createParticipants: rawParticipants.map((participant) => ({
raw: participant,
})),
};
},
};
});
describe('*** Operator: Thread Handlers tests ***', () => {
let operator: ServerDataOperator;
beforeAll(async () => {
await DatabaseManager.init(['baseHandler.test.com']);
operator = DatabaseManager.serverDatabases['baseHandler.test.com'].operator;
});
it('=> HandleThreads: should write to the the Thread & ThreadParticipant table', async () => {
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords');
const spyOnHandleThreadParticipants = jest.spyOn(operator, 'handleThreadParticipants');
const threads = [
{
id: 'thread-1',
reply_count: 2,
last_reply_at: 123,
last_viewed_at: 123,
participants: [{
id: 'user-1',
}],
is_following: true,
unread_replies: 0,
unread_mentions: 0,
},
] as Thread[];
await operator.handleThreads({threads, prepareRecordsOnly: false});
expect(spyOnHandleRecords).toHaveBeenCalledWith({
findMatchingRecordBy: isRecordThreadEqualToRaw,
fieldName: 'id',
transformer: transformThreadRecord,
createOrUpdateRawValues: threads,
tableName: 'Thread',
prepareRecordsOnly: true,
});
// Should handle participants
expect(spyOnHandleThreadParticipants).toHaveBeenCalledWith({
threadsParticipants: threads.map((thread) => ({
thread_id: thread.id,
participants: thread.participants.map((participant) => ({
id: participant.id,
thread_id: thread.id,
})),
})),
prepareRecordsOnly: true,
});
// Only one batch operation for both tables
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1);
});
it('=> HandleThreadParticipants: should write to the the ThreadParticipant table', async () => {
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const threadsParticipants = [
{
thread_id: 'thread-1',
participants: [{
id: 'user-1',
thread_id: 'thread-1',
}],
},
];
await operator.handleThreadParticipants({threadsParticipants, prepareRecordsOnly: false});
expect(spyOnPrepareRecords).toHaveBeenCalledWith({
createRaws: [{
raw: threadsParticipants[0].participants[0],
}],
transformer: transformThreadParticipantRecord,
tableName: 'ThreadParticipant',
});
});
});

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Model from '@nozbe/watermelondb/Model';
import {Database} from '@constants';
import DataOperatorException from '@database/exceptions/data_operator_exception';
import {isRecordThreadEqualToRaw} from '@database/operator/server_data_operator/comparators';
import {
transformThreadRecord,
transformThreadParticipantRecord,
} from '@database/operator/server_data_operator/transformers/thread';
import {getUniqueRawsBy} from '@database/operator/utils/general';
import {sanitizeThreadParticipants} from '@database/operator/utils/thread';
import type {HandleThreadsArgs, HandleThreadParticipantsArgs} from '@typings/database/database';
import type ThreadModel from '@typings/database/models/servers/thread';
import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant';
const {
THREAD,
THREAD_PARTICIPANT,
} = Database.MM_TABLES.SERVER;
export interface ThreadHandlerMix {
handleThreads: ({threads, prepareRecordsOnly}: HandleThreadsArgs) => Promise<Model[]>;
handleThreadParticipants: ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs) => Promise<ThreadParticipantModel[]>;
}
const ThreadHandler = (superclass: any) => class extends superclass {
/**
* handleThreads: Handler responsible for the Create/Update operations occurring on the Thread table from the 'Server' schema
* @param {HandleThreadsArgs} handleThreads
* @param {Thread[]} handleThreads.threads
* @param {boolean | undefined} handleThreads.prepareRecordsOnly
* @returns {Promise<void>}
*/
handleThreads = async ({threads, prepareRecordsOnly = false}: HandleThreadsArgs): Promise<Model[]> => {
if (!threads.length) {
throw new DataOperatorException(
'An empty "threads" array has been passed to the handleThreads method',
);
}
// Get unique threads in case they are duplicated
const uniqueThreads = getUniqueRawsBy({
raws: threads,
key: 'id',
}) as Thread[];
const threadsParticipants: ParticipantsPerThread[] = [];
// Let's process the thread data
for (const thread of uniqueThreads) {
threadsParticipants.push({
thread_id: thread.id,
participants: (thread.participants || []).map((participant) => ({
id: participant.id,
thread_id: thread.id,
})),
});
}
// Get thread models to be created and updated
const preparedThreads = await this.handleRecords({
fieldName: 'id',
findMatchingRecordBy: isRecordThreadEqualToRaw,
transformer: transformThreadRecord,
prepareRecordsOnly: true,
createOrUpdateRawValues: uniqueThreads,
tableName: THREAD,
}) as ThreadModel[];
// Add the models to be batched here
const batch: Model[] = [...preparedThreads];
// calls handler for Thread Participants
const threadParticipants = (await this.handleThreadParticipants({threadsParticipants, prepareRecordsOnly: true})) as ThreadParticipantModel[];
batch.push(...threadParticipants);
if (batch.length && !prepareRecordsOnly) {
await this.batchRecords(batch);
}
return batch;
};
/**
* handleThreadParticipants: Handler responsible for the Create/Update operations occurring on the ThreadParticipants table from the 'Server' schema
* @param {HandleThreadParticipantsArgs} handleThreadParticipants
* @param {ParticipantsPerThread[]} handleThreadParticipants.threadsParticipants
* @param {boolean} handleThreadParticipants.prepareRecordsOnly
* @throws DataOperatorException
* @returns {Promise<Array<ThreadParticipantModel>>}
*/
handleThreadParticipants = async ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs): Promise<ThreadParticipantModel[]> => {
const batchRecords: ThreadParticipantModel[] = [];
if (!threadsParticipants.length) {
throw new DataOperatorException(
'An empty "thread participants" array has been passed to the handleThreadParticipants method',
);
}
for await (const threadParticipant of threadsParticipants) {
const {thread_id, participants} = threadParticipant;
const rawValues = getUniqueRawsBy({raws: participants, key: 'id'}) as ThreadParticipant[];
const {
createParticipants,
deleteParticipants,
} = await sanitizeThreadParticipants({
database: this.database,
thread_id,
rawParticipants: rawValues,
});
if (createParticipants?.length) {
// Prepares record for model ThreadParticipants
const participantsRecords = (await this.prepareRecords({
createRaws: createParticipants,
transformer: transformThreadParticipantRecord,
tableName: THREAD_PARTICIPANT,
})) as ThreadParticipantModel[];
batchRecords.push(...participantsRecords);
}
if (deleteParticipants?.length) {
batchRecords.push(...deleteParticipants);
}
}
if (prepareRecordsOnly) {
return batchRecords;
}
if (batchRecords?.length) {
await this.batchRecords(batchRecords);
}
return batchRecords;
};
};
export default ThreadHandler;

View File

@@ -9,13 +9,14 @@ import PostsInChannelHandler, {PostsInChannelHandlerMix} from '@database/operato
import PostsInThreadHandler, {PostsInThreadHandlerMix} from '@database/operator/server_data_operator/handlers/posts_in_thread';
import ReactionHander, {ReactionHandlerMix} from '@database/operator/server_data_operator/handlers/reaction';
import TeamHandler, {TeamHandlerMix} from '@database/operator/server_data_operator/handlers/team';
import ThreadHandler, {ThreadHandlerMix} from '@database/operator/server_data_operator/handlers/thread';
import UserHandler, {UserHandlerMix} from '@database/operator/server_data_operator/handlers/user';
import mix from '@utils/mix';
import type {Database} from '@nozbe/watermelondb';
interface ServerDataOperator extends ServerDataOperatorBase, PostHandlerMix, PostsInChannelHandlerMix,
PostsInThreadHandlerMix, ReactionHandlerMix, UserHandlerMix, ChannelHandlerMix, CategoryHandlerMix, TeamHandlerMix {}
PostsInThreadHandlerMix, ReactionHandlerMix, UserHandlerMix, ChannelHandlerMix, CategoryHandlerMix, TeamHandlerMix, ThreadHandlerMix {}
class ServerDataOperator extends mix(ServerDataOperatorBase).with(
CategoryHandler,
@@ -25,6 +26,7 @@ class ServerDataOperator extends mix(ServerDataOperatorBase).with(
PostsInThreadHandler,
ReactionHander,
TeamHandler,
ThreadHandler,
UserHandler,
) {
// eslint-disable-next-line no-useless-constructor

View File

@@ -0,0 +1,73 @@
// 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/server_data_operator/transformers/index';
import {OperationType} from '@typings/database/enums';
import type {TransformerArgs} from '@typings/database/database';
import type ThreadModel from '@typings/database/models/servers/thread';
import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant';
const {
THREAD,
THREAD_PARTICIPANT,
} = MM_TABLES.SERVER;
/**
* transformThreadRecord: Prepares a record of the SERVER database 'Thread' table for update or create actions.
* @param {TransformerArgs} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<ThreadModel>}
*/
export const transformThreadRecord = ({action, database, value}: TransformerArgs): Promise<ThreadModel> => {
const raw = value.raw as Thread;
const record = value.record as ThreadModel;
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 fieldsMapper = (thread: ThreadModel) => {
thread._raw.id = isCreateAction ? (raw?.id ?? thread.id) : record.id;
thread.lastReplyAt = raw.last_reply_at;
thread.lastViewedAt = raw.last_viewed_at;
thread.replyCount = raw.reply_count;
thread.isFollowing = raw.is_following ?? record?.isFollowing;
thread.unreadReplies = raw.unread_replies;
thread.unreadMentions = raw.unread_mentions;
thread.loadedInGlobalThreads = raw.loaded_in_global_threads || record?.loadedInGlobalThreads;
};
return prepareBaseRecord({
action,
database,
tableName: THREAD,
value,
fieldsMapper,
}) as Promise<ThreadModel>;
};
/**
* transformThreadParticipantRecord: Prepares a record of the SERVER database 'ThreadParticipant' table for update or create actions.
* @param {TransformerArgs} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<ThreadParticipantModel>}
*/
export const transformThreadParticipantRecord = ({action, database, value}: TransformerArgs): Promise<ThreadParticipantModel> => {
const raw = value.raw as ThreadParticipant;
// id of participant comes from server response
const fieldsMapper = (participant: ThreadParticipantModel) => {
participant.threadId = raw.thread_id;
participant.userId = raw.id;
};
return prepareBaseRecord({
action,
database,
tableName: THREAD_PARTICIPANT,
value,
fieldsMapper,
}) as Promise<ThreadParticipantModel>;
};

View File

@@ -28,7 +28,6 @@ export const mockedPosts = {
pending_post_id: '',
reply_count: 4,
last_reply_at: 0,
participants: null,
metadata: {},
},
'8fcnk3p1jt8mmkaprgajoxz115a': {
@@ -55,7 +54,6 @@ export const mockedPosts = {
pending_post_id: '',
reply_count: 0,
last_reply_at: 0,
participants: null,
metadata: {},
},
'3y3w3a6gkbg73bnj3xund9o5ic': {
@@ -77,7 +75,6 @@ export const mockedPosts = {
pending_post_id: '',
reply_count: 4,
last_reply_at: 0,
participants: null,
metadata: {},
},
'4btbnmticjgw7ewd3qopmpiwqw': {
@@ -101,7 +98,6 @@ export const mockedPosts = {
pending_post_id: '',
reply_count: 0,
last_reply_at: 0,
participants: null,
metadata: {},
},
'4r9jmr7eqt8dxq3f9woypzurry': {
@@ -124,7 +120,6 @@ export const mockedPosts = {
has_reactions: true,
reply_count: 7,
last_reply_at: 0,
participants: null,
metadata: {
reactions: [
{
@@ -165,7 +160,6 @@ export const mockedPosts = {
pending_post_id: '',
reply_count: 7,
last_reply_at: 0,
participants: null,
metadata: {
emojis: [
{

View File

@@ -0,0 +1,51 @@
// 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 type {RecordPair, SanitizeThreadParticipantsArgs} from '@typings/database/database';
import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant';
const {THREAD_PARTICIPANT} = MM_TABLES.SERVER;
/**
* sanitizeThreadParticipants: Treats participants in a Thread. For example, a user can participate/not. Hence, this function
* tell us which participants to create/delete in the ThreadParticipants table.
* @param {SanitizeThreadParticipantsArgs} sanitizeThreadParticipants
* @param {Database} sanitizeThreadParticipants.database
* @param {string} sanitizeThreadParticipants.thread_id
* @param {UserProfile[]} sanitizeThreadParticipants.rawParticipants
* @returns {Promise<{createParticipants: ThreadParticipant[], deleteParticipants: ThreadParticipantModel[]}>}
*/
export const sanitizeThreadParticipants = async ({database, thread_id, rawParticipants}: SanitizeThreadParticipantsArgs) => {
const participants = (await database.collections.
get(THREAD_PARTICIPANT).
query(Q.where('thread_id', thread_id)).
fetch()) as ThreadParticipantModel[];
// similarObjects: Contains objects that are in both the RawParticipant array and in the ThreadParticipant table
const similarObjects: ThreadParticipantModel[] = [];
const createParticipants: RecordPair[] = [];
for (let i = 0; i < rawParticipants.length; i++) {
const rawParticipant = rawParticipants[i];
// If the participant is not present let's add them to the db
const exists = participants.find((participant) => participant.userId === rawParticipant.id);
if (exists) {
similarObjects.push(exists);
} else {
createParticipants.push({raw: rawParticipant});
}
}
// finding out elements to delete using array subtract
const deleteParticipants = participants.
filter((participant) => !similarObjects.includes(participant)).
map((outCast) => outCast.prepareDestroyPermanently());
return {createParticipants, deleteParticipants};
};

View File

@@ -28,6 +28,8 @@ import {
TeamSchema,
TeamSearchHistorySchema,
TermsOfServiceSchema,
ThreadSchema,
ThreadParticipantSchema,
UserSchema,
} from './table_schemas';
@@ -58,6 +60,8 @@ export const serverSchema: AppSchema = appSchema({
TeamSchema,
TeamSearchHistorySchema,
TermsOfServiceSchema,
ThreadSchema,
ThreadParticipantSchema,
UserSchema,
],
});

View File

@@ -25,4 +25,6 @@ export {default as TeamMembershipSchema} from './team_membership';
export {default as TeamSchema} from './team';
export {default as TeamSearchHistorySchema} from './team_search_history';
export {default as TermsOfServiceSchema} from './terms_of_service';
export {default as ThreadSchema} from './thread';
export {default as ThreadParticipantSchema} from './thread_participant';
export {default as UserSchema} from './user';

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
const {THREAD} = MM_TABLES.SERVER;
export default tableSchema({
name: THREAD,
columns: [
{name: 'last_reply_at', type: 'number'},
{name: 'last_viewed_at', type: 'number'},
{name: 'is_following', type: 'boolean'},
{name: 'reply_count', type: 'number'},
{name: 'unread_replies', type: 'number'},
{name: 'unread_mentions', type: 'number'},
{name: 'loaded_in_global_threads', type: 'boolean'},
],
});

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
const {THREAD_PARTICIPANT} = MM_TABLES.SERVER;
export default tableSchema({
name: THREAD_PARTICIPANT,
columns: [
{name: 'thread_id', type: 'string', isIndexed: true},
{name: 'user_id', type: 'string', isIndexed: true},
],
});

View File

@@ -32,6 +32,8 @@ const {
TEAM_MEMBERSHIP,
TEAM_SEARCH_HISTORY,
TERMS_OF_SERVICE,
THREAD,
THREAD_PARTICIPANT,
USER,
} = MM_TABLES.SERVER;
@@ -459,6 +461,40 @@ describe('*** Test schema for SERVER database ***', () => {
},
columnArray: [{name: 'accepted_at', type: 'number'}],
},
[THREAD]: {
name: THREAD,
unsafeSql: undefined,
columns: {
last_reply_at: {name: 'last_reply_at', type: 'number'},
last_viewed_at: {name: 'last_viewed_at', type: 'number'},
is_following: {name: 'is_following', type: 'boolean'},
reply_count: {name: 'reply_count', type: 'number'},
unread_replies: {name: 'unread_replies', type: 'number'},
unread_mentions: {name: 'unread_mentions', type: 'number'},
loaded_in_global_threads: {name: 'loaded_in_global_threads', type: 'boolean'},
},
columnArray: [
{name: 'last_reply_at', type: 'number'},
{name: 'last_viewed_at', type: 'number'},
{name: 'is_following', type: 'boolean'},
{name: 'reply_count', type: 'number'},
{name: 'unread_replies', type: 'number'},
{name: 'unread_mentions', type: 'number'},
{name: 'loaded_in_global_threads', type: 'boolean'},
],
},
[THREAD_PARTICIPANT]: {
name: THREAD_PARTICIPANT,
unsafeSql: undefined,
columns: {
thread_id: {name: 'thread_id', type: 'string', isIndexed: true},
user_id: {name: 'user_id', type: 'string', isIndexed: true},
},
columnArray: [
{name: 'thread_id', type: 'string', isIndexed: true},
{name: 'user_id', type: 'string', isIndexed: true},
],
},
[USER]: {
name: USER,
unsafeSql: undefined,

View File

@@ -326,7 +326,6 @@ export function generateCombinedPost(combinedId: string, systemPosts: PostModel[
type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType,
user_id: '',
metadata: {},
participants: null,
};
}

View File

@@ -22,6 +22,7 @@ interface ClientConfig {
BuildHashEnterprise: string;
BuildNumber: string;
CloseUnusedDirectMessages: string;
CollapsedThreads: string;
CustomBrandText: string;
CustomDescriptionText: string;
CustomTermsOfServiceId: string;
@@ -115,6 +116,7 @@ interface ClientConfig {
ExperimentalViewArchivedChannels: string;
ExtendSessionLengthWithActivity: string;
FeatureFlagAppsEnabled?: string;
FeatureFlagCollapsedThreads?: string;
GfycatApiKey: string;
GfycatApiSecret: string;
GoogleDeveloperKey: string;

View File

@@ -47,6 +47,7 @@ type Post = {
update_at: number;
edit_at: number;
delete_at: number;
is_following?: boolean;
is_pinned: boolean;
user_id: string;
channel_id: string;
@@ -54,6 +55,7 @@ type Post = {
original_id: string;
message: string;
type: PostType;
participants?: null | UserProfile[];
props: Record<string, any>;
hashtags: string;
pending_post_id: string;

20
types/api/threads.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type Thread = {
id: string;
reply_count: number;
last_reply_at: number;
last_viewed_at: number;
participants: UserProfile[];
post: Post;
is_following?: boolean;
unread_replies: number;
unread_mentions: number;
loaded_in_global_threads: boolean;
};
type ThreadParticipant = {
id: $ID<User>;
thread_id: $ID<Thread>;
};

View File

@@ -88,6 +88,16 @@ export type HandlePostsArgs = {
prepareRecordsOnly?: boolean;
};
export type HandleThreadsArgs = {
threads: Thread[];
prepareRecordsOnly?: boolean;
};
export type HandleThreadParticipantsArgs = {
prepareRecordsOnly: boolean;
threadsParticipants: ParticipantsPerThread[];
};
export type SanitizeReactionsArgs = {
database: Database;
post_id: string;
@@ -95,6 +105,12 @@ export type SanitizeReactionsArgs = {
skipSync?: boolean;
};
export type SanitizeThreadParticipantsArgs = {
database: Database;
thread_id: $ID<Thread>;
rawParticipants: ThreadParticipant[];
}
export type ChainPostsArgs = {
order: string[];
previousPostId: string;

View File

@@ -79,6 +79,9 @@ export default class PostModel extends Model {
/** channel: The channel which is presenting this Post */
channel: Relation<ChannelModel>;
/** thread : the related thread for the post */
thread: Relation<ThreadModel>;
/** hasReplies: Async function to determine if the post is part of a thread */
hasReplies: () => Promise<boolean>;
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Query, Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
/**
* The Thread model contains thread information of a post.
*/
export default class ThreadModel extends Model {
/** table (name) : Thread */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** lastReplyAt : The timestamp of when user last replied to the thread. */
lastReplyAt: number;
/** lastViewedAt : The timestamp of when user last viewed the thread. */
lastViewedAt: number;
/** reply_count : The total replies to the thread by all the participants. */
replyCount: number;
/** isFollowing: If user is following this thread or not */
isFollowing: boolean;
/** unread_replies : The number of replies that are not read by the user. */
unreadReplies: number;
/** unread_mentions : The number of mentions that are not read by the user. */
unreadMentions: number;
/** loaded_in_global_threads : Flag to differentiate the unread threads loaded for showing unread counts/mentions */
loadedInGlobalThreads: boolean;
/** participants: All the participants of the thread */
participants: Query<ThreadParticipantsModel>;
/** post : Query returning the post data for the current thread */
post: Relation<PostModel>;
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Relation} from '@nozbe/watermelondb';
import Model, {Associations} from '@nozbe/watermelondb/Model';
/**
* The Thread Participants Model is used to show the participants of a thread
*/
export default class ThreadParticipantsModel extends Model {
/** table (name) : ThreadParticipants */
static table: string;
/** associations : Describes every relationship to this table. */
static associations: Associations;
/** thread_id : The related Thread's foreign key to which this participant belongs */
threadId: string;
/** user_id : The user id of the user participating in the thread */
userId: string;
/** thread : The related record to the Thread model */
thread: Relation<ThreadModel>;
/** user : The related record to the User model */
user: Relation<UserModel>;
}

View File

@@ -88,6 +88,9 @@ export default class UserModel extends Model {
/** teams : All the team that this user is part of */
teams: Query<TeamMembershipModel>;
/** threadParticipations : All the thread participations this user is part of */
threadParticipations: Query<ThreadParticipantsModel>;
/** prepareStatus: Prepare the model to update the user status in a batch operation */
prepareStatus: (status: string) => void;

View File

@@ -57,6 +57,11 @@ type IdValue = {
value: unknown;
};
type ParticipantsPerThread = {
thread_id: string;
participants: ThreadParticipant[];
};
type TeamChannelHistory = {
id: string;
channel_ids: string[];
@@ -103,5 +108,7 @@ type RawValue =
| TeamMembership
| TeamSearchHistory
| TermsOfService
| Thread
| ThreadParticipant
| UserProfile
| Pick<ChannelMembership, 'channel_id' | 'user_id'>