diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 6238b5472..4fbbc4b9e 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -25,7 +25,7 @@ import {fetchGroupsByNames} from './groups'; import {forceLogoutIfNecessary} from './session'; import type {Model} from '@nozbe/watermelondb'; -import type {CustomAttribute, CustomProfileAttributeSimple, CustomProfileField, CustomAttributeSet} from '@typings/api/custom_profile_attributes'; +import type {CustomAttribute, CustomProfileField, CustomAttributeSet, UserCustomProfileAttributeSimple} from '@typings/api/custom_profile_attributes'; import type UserModel from '@typings/database/models/servers/user'; export type MyUserRequest = { @@ -914,7 +914,7 @@ export const fetchCustomAttributes = async (serverUrl: string, userId: string, f export const updateCustomAttributes = async (serverUrl: string, attributes: CustomAttributeSet): Promise<{success: boolean; error: unknown}> => { try { const client = NetworkManager.getClient(serverUrl); - const values: CustomProfileAttributeSimple = {}; + const values: UserCustomProfileAttributeSimple = {}; Object.keys(attributes).forEach((field) => { values[field] = attributes[field].value; }); diff --git a/app/client/rest/custom_profile_attributes.ts b/app/client/rest/custom_profile_attributes.ts index 7bba6adb1..aa67ded5a 100644 --- a/app/client/rest/custom_profile_attributes.ts +++ b/app/client/rest/custom_profile_attributes.ts @@ -2,12 +2,12 @@ // See LICENSE.txt for license information. import type ClientBase from './base'; -import type {CustomProfileAttributeSimple, CustomProfileField} from '@typings/api/custom_profile_attributes'; +import type {CustomProfileField, UserCustomProfileAttributeSimple} from '@typings/api/custom_profile_attributes'; export interface ClientCustomAttributesMix { getCustomProfileAttributeFields: () => Promise; - getCustomProfileAttributeValues: (userID: string) => Promise; - updateCustomProfileAttributeValues: (values: CustomProfileAttributeSimple) => Promise; + getCustomProfileAttributeValues: (userID: string) => Promise; + updateCustomProfileAttributeValues: (values: UserCustomProfileAttributeSimple) => Promise; } const ClientCustomAttributes = >(superclass: TBase) => class extends superclass { @@ -24,7 +24,7 @@ const ClientCustomAttributes = >(superclas {method: 'get'}, ); }; - updateCustomProfileAttributeValues = async (values: CustomProfileAttributeSimple) => { + updateCustomProfileAttributeValues = async (values: UserCustomProfileAttributeSimple) => { return this.doFetch( `${this.getCustomProfileAttributesRoute()}/values`, { diff --git a/app/constants/database.ts b/app/constants/database.ts index 428eb690d..e40765400 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -18,6 +18,8 @@ export const MM_TABLES = { CHANNEL_MEMBERSHIP: 'ChannelMembership', CONFIG: 'Config', CUSTOM_EMOJI: 'CustomEmoji', + CUSTOM_PROFILE_FIELD: 'CustomProfileField', + CUSTOM_PROFILE_ATTRIBUTE: 'CustomProfileAttribute', DRAFT: 'Draft', FILE: 'File', GROUP: 'Group', diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 7b41f7d23..4fa30e467 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -11,7 +11,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -47,7 +47,7 @@ class DatabaseManagerSingleton { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 968d03c07..86b8e7c35 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -12,7 +12,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -44,7 +44,7 @@ class DatabaseManagerSingleton { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 4fe42d8bf..321a4ce9e 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -8,9 +8,36 @@ import {addColumns, createTable, schemaMigrations, unsafeExecuteSql} from '@nozb import {MM_TABLES} from '@constants/database'; -const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, POST, CHANNEL} = MM_TABLES.SERVER; +const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, POST, CHANNEL, CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD} = MM_TABLES.SERVER; export default schemaMigrations({migrations: [ + { + toVersion: 8, + steps: [ + createTable({ + name: CUSTOM_PROFILE_ATTRIBUTE, + columns: [ + {name: 'field_id', type: 'string', isIndexed: true}, + {name: 'user_id', type: 'string', isIndexed: true}, + {name: 'value', type: 'string'}, + ], + }), + createTable({ + name: CUSTOM_PROFILE_FIELD, + columns: [ + {name: 'group_id', type: 'string', isIndexed: true}, + {name: 'name', type: 'string'}, + {name: 'type', type: 'string'}, + {name: 'target_id', type: 'string'}, + {name: 'target_type', type: 'string'}, + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number', isOptional: true}, + {name: 'attrs', type: 'string', isOptional: true}, + ], + }), + ], + }, { toVersion: 7, steps: [ diff --git a/app/database/models/server/custom_profile_attribute.ts b/app/database/models/server/custom_profile_attribute.ts new file mode 100644 index 000000000..7b3cd2172 --- /dev/null +++ b/app/database/models/server/custom_profile_attribute.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {field, immutableRelation} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type CustomProfileFieldModel from './custom_profile_field'; +import type UserModel from './user'; +import type {Relation} from '@nozbe/watermelondb'; +import type CustomProfileAttributeModelInterface from '@typings/database/models/servers/custom_profile_attribute'; + +const {CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD, USER} = MM_TABLES.SERVER; + +/** + * The CustomProfileAttribute model represents the 'CUSTOM_PROFILE_ATTRIBUTE' table and stores + * the custom profile attribute values for users. + */ +export default class CustomProfileAttributeModel extends Model implements CustomProfileAttributeModelInterface { + /** table (name) : CustomProfileAttribute */ + static table = CUSTOM_PROFILE_ATTRIBUTE; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + [CUSTOM_PROFILE_FIELD]: {type: 'belongs_to', key: 'field_id'}, + [USER]: {type: 'belongs_to', key: 'user_id'}, + }; + + /** field_id : The identifier of the custom profile field this attribute is for */ + @field('field_id') fieldId!: string; + + /** user_id : The identifier of the user this attribute belongs to */ + @field('user_id') userId!: string; + + /** value : The value of the custom profile attribute */ + @field('value') value!: string; + + /** Relation to the custom profile field */ + @immutableRelation(CUSTOM_PROFILE_FIELD, 'field_id') field!: Relation; + + /** Relation to the user */ + @immutableRelation(USER, 'user_id') user!: Relation; +} diff --git a/app/database/models/server/custom_profile_field.ts b/app/database/models/server/custom_profile_field.ts new file mode 100644 index 000000000..fc61e90d2 --- /dev/null +++ b/app/database/models/server/custom_profile_field.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {field, json, children} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; +import {safeParseJSON} from '@utils/helpers'; + +import type CustomProfileAttributeModel from './custom_profile_attribute'; +import type {Query} from '@nozbe/watermelondb'; +import type CustomProfileFieldModelInterface from '@typings/database/models/servers/custom_profile_field'; + +const {CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD} = MM_TABLES.SERVER; + +/** + * The CustomProfileField model represents the 'CUSTOM_PROFILE_FIELD' table and defines + * the custom profile fields available in the system. + */ +export default class CustomProfileFieldModel extends Model implements CustomProfileFieldModelInterface { + /** table (name) : CustomProfileField */ + static table = CUSTOM_PROFILE_FIELD; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + [CUSTOM_PROFILE_ATTRIBUTE]: {type: 'has_many', foreignKey: 'field_id'}, + }; + + /** group_id : The identifier of the group this field belongs to */ + @field('group_id') groupId!: string; + + /** name : The name of the custom profile field */ + @field('name') name!: string; + + /** type : The type of values accepted (e.g., 'text') */ + @field('type') type!: string; + + /** target_id : The id of the target element (empty if system property) */ + @field('target_id') targetId!: string; + + /** target_type : The type of element this is assigned to (e.g., 'user', 'post', 'card') */ + @field('target_type') targetType!: string; + + /** create_at : The timestamp when this field was created */ + @field('create_at') createAt!: number; + + /** update_at : The timestamp when this field was last updated */ + @field('update_at') updateAt!: number; + + /** delete_at : The timestamp when this field was deleted (0 if not deleted) */ + @field('delete_at') deleteAt!: number; + + /** attrs : Any extra properties of the field */ + @json('attrs', safeParseJSON) attrs!: { + sort_order?: number; + [key: string]: unknown; + } | null; + + /** customProfileAttributes : All the custom profile attributes for this field */ + @children(CUSTOM_PROFILE_ATTRIBUTE) customProfileAttributes!: Query; +} diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index 6ea0df906..785a1aa65 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -9,6 +9,8 @@ export {default as ChannelInfoModel} from './channel_info'; export {default as ChannelMembershipModel} from './channel_membership'; export {default as ConfigModel} from './config'; export {default as CustomEmojiModel} from './custom_emoji'; +export {default as CustomProfileFieldModel} from './custom_profile_field'; +export {default as CustomProfileAttributeModel} from './custom_profile_attribute'; export {default as DraftModel} from './draft'; export {default as FileModel} from './file'; export {default as GroupModel} from './group'; diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index 1edbaba54..cd85ee67c 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -7,6 +7,7 @@ import Model, {type Associations} from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; import {safeParseJSON} from '@utils/helpers'; +import type CustomProfileAttributeModel from './custom_profile_attribute'; import type {Query} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; @@ -22,6 +23,7 @@ const { CHANNEL, CHANNEL_BOOKMARK, CHANNEL_MEMBERSHIP, + CUSTOM_PROFILE_ATTRIBUTE, POST, PREFERENCE, REACTION, @@ -64,6 +66,9 @@ export default class UserModel extends Model implements UserModelInterface { /** USER has a 1:N relationship with THREAD_PARTICIPANT. A user can participate in multiple threads */ [THREAD_PARTICIPANT]: {type: 'has_many', foreignKey: 'user_id'}, + + /** USER has a 1:N relationship with CUSTOM_PROFILE_ATTRIBUTE. A user can have multiple custom profile attributes */ + [CUSTOM_PROFILE_ATTRIBUTE]: {type: 'has_many', foreignKey: 'user_id'}, }; /** auth_service : The type of authentication service registered to that user */ @@ -152,6 +157,9 @@ export default class UserModel extends Model implements UserModelInterface { /** threadParticipations : All the thread participations this user is part of */ @children(THREAD_PARTICIPANT) threadParticipations!: Query; + /** USER has a 1:N relationship with CUSTOM_PROFILE_ATTRIBUTE. A user can have multiple custom profile attributes */ + @children(CUSTOM_PROFILE_ATTRIBUTE) customProfileAttributes!: Query | undefined; + prepareStatus = (status: string) => { this.prepareUpdate((u) => { u.status = status; diff --git a/app/database/operator/base_data_operator/index.ts b/app/database/operator/base_data_operator/index.ts index 7105adc6e..068f871b2 100644 --- a/app/database/operator/base_data_operator/index.ts +++ b/app/database/operator/base_data_operator/index.ts @@ -86,7 +86,6 @@ export default class BaseDataOperator { if (createOrUpdateRawValues.length > 0) { for (const newElement of createOrUpdateRawValues) { - // @ts-expect-error object with string key const key = buildKeyRecordBy?.(newElement) || newElement[fieldName]; const existingRecord = recordsByKeys[key]; diff --git a/app/database/operator/server_data_operator/handlers/custom_profile.test.ts b/app/database/operator/server_data_operator/handlers/custom_profile.test.ts new file mode 100644 index 000000000..452a207f6 --- /dev/null +++ b/app/database/operator/server_data_operator/handlers/custom_profile.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import { + transformCustomProfileFieldRecord, + transformCustomProfileAttributeRecord, +} from '@database/operator/server_data_operator/transformers/custom_profile'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {CustomProfileField, CustomProfileAttribute} from '@typings/api/custom_profile_attributes'; + +describe('*** Operator: Custom Profile Handlers tests ***', () => { + let operator: ServerDataOperator; + const serverUrl = 'baseHandler.test.com'; + + beforeAll(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + }); + describe('=> handleCustomProfileFields', () => { + it('should write to CustomProfileField table', async () => { + expect.assertions(2); + + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const fields: CustomProfileField[] = [ + { + id: 'field1', + name: 'Test Field', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 0, + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + ]; + + await operator.handleCustomProfileFields({ + fields, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: fields, + tableName: MM_TABLES.SERVER.CUSTOM_PROFILE_FIELD, + prepareRecordsOnly: false, + transformer: transformCustomProfileFieldRecord, + }, 'handleCustomProfileFields'); + }); + + it('should properly pass prepareRecordsOnly when set to true', async () => { + expect.assertions(2); + + const originalHandleRecords = operator.handleRecords; + operator.handleRecords = jest.fn().mockResolvedValue([]); + + const fields: CustomProfileField[] = [ + { + id: 'field1', + name: 'Test Field', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 0, + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + ]; + + await operator.handleCustomProfileFields({ + fields, + prepareRecordsOnly: true, + }); + + expect(operator.handleRecords).toHaveBeenCalledTimes(1); + expect(operator.handleRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: fields, + tableName: MM_TABLES.SERVER.CUSTOM_PROFILE_FIELD, + prepareRecordsOnly: true, + transformer: transformCustomProfileFieldRecord, + }, 'handleCustomProfileFields'); + + operator.handleRecords = originalHandleRecords; + }); + + it('should handle empty fields array', async () => { + expect.assertions(1); + + const result = await operator.handleCustomProfileFields({ + fields: [], + prepareRecordsOnly: false, + }); + + expect(result).toEqual([]); + }); + + it('should handle duplicate fields by using unique id', async () => { + expect.assertions(2); + + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const fields: CustomProfileField[] = [ + { + id: 'field1', + name: 'Test Field 1', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 0, + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + { + id: 'field1', + name: 'Test Field 2', + type: 'text', + create_at: 1607683720173, + update_at: 1607683720173, + delete_at: 0, + group_id: 'group1', + target_id: 'target1', + target_type: 'user', + attrs: {}, + }, + ]; + + await operator.handleCustomProfileFields({ + fields, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); + + expect(spyOnHandleRecords).toHaveBeenCalledWith( + expect.objectContaining({ + createOrUpdateRawValues: [fields[1]], + }), + 'handleCustomProfileFields', + ); + }); + }); + + describe('=> handleCustomProfileAttributes', () => { + it('should write to CustomProfileAttribute table', async () => { + expect.assertions(2); + + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const attributes: CustomProfileAttribute[] = [ + { + id: 'field1-user1', + field_id: 'field1', + user_id: 'user1', + value: 'Test Value', + }, + ]; + + await operator.handleCustomProfileAttributes({ + attributes, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); + expect(spyOnHandleRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: attributes, + tableName: MM_TABLES.SERVER.CUSTOM_PROFILE_ATTRIBUTE, + prepareRecordsOnly: false, + transformer: transformCustomProfileAttributeRecord, + }, 'handleCustomProfileAttributes'); + }); + + it('should properly pass prepareRecordsOnly when set to true', async () => { + expect.assertions(2); + + const originalHandleRecords = operator.handleRecords; + operator.handleRecords = jest.fn().mockResolvedValue([]); + + const attributes: CustomProfileAttribute[] = [ + { + id: 'field1-user1', + field_id: 'field1', + user_id: 'user1', + value: 'Test Value', + }, + ]; + + await operator.handleCustomProfileAttributes({ + attributes, + prepareRecordsOnly: true, + }); + + expect(operator.handleRecords).toHaveBeenCalledTimes(1); + expect(operator.handleRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: attributes, + tableName: MM_TABLES.SERVER.CUSTOM_PROFILE_ATTRIBUTE, + prepareRecordsOnly: true, + transformer: transformCustomProfileAttributeRecord, + }, 'handleCustomProfileAttributes'); + + // Restore original method + operator.handleRecords = originalHandleRecords; + }); + + it('should handle empty attributes array', async () => { + expect.assertions(1); + + const result = await operator.handleCustomProfileAttributes({ + attributes: [], + prepareRecordsOnly: false, + }); + + expect(result).toEqual([]); + }); + + it('should handle duplicate attributes by using unique id', async () => { + expect.assertions(2); + + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const attributes: CustomProfileAttribute[] = [ + { + id: 'field1-user1', + field_id: 'field1', + user_id: 'user1', + value: 'Test Value 1', + }, + { + id: 'field1-user1', + field_id: 'field1', + user_id: 'user1', + value: 'Test Value 2', + }, + ]; + + await operator.handleCustomProfileAttributes({ + attributes, + prepareRecordsOnly: false, + }); + + expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); + + expect(spyOnHandleRecords).toHaveBeenCalledWith( + expect.objectContaining({ + createOrUpdateRawValues: [attributes[1]], + }), + 'handleCustomProfileAttributes', + ); + }); + }); + +}); diff --git a/app/database/operator/server_data_operator/handlers/custom_profile.ts b/app/database/operator/server_data_operator/handlers/custom_profile.ts new file mode 100644 index 000000000..7dd57f7d1 --- /dev/null +++ b/app/database/operator/server_data_operator/handlers/custom_profile.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import { + transformCustomProfileFieldRecord, + transformCustomProfileAttributeRecord, +} from '@database/operator/server_data_operator/transformers/custom_profile'; +import {getUniqueRawsBy} from '@database/operator/utils/general'; +import {logWarning} from '@utils/log'; + +import type ServerDataOperatorBase from '.'; +import type Model from '@nozbe/watermelondb/Model'; +import type { + HandleCustomProfileFieldsArgs, + HandleCustomProfileAttributesArgs, +} from '@typings/database/database'; + +const {CUSTOM_PROFILE_FIELD, CUSTOM_PROFILE_ATTRIBUTE} = MM_TABLES.SERVER; + +export interface CustomProfileHandlerMix { + handleCustomProfileFields: ({fields, prepareRecordsOnly}: HandleCustomProfileFieldsArgs) => Promise; + handleCustomProfileAttributes: ({attributes, prepareRecordsOnly}: HandleCustomProfileAttributesArgs) => Promise; +} + +const CustomProfileHandler = >(superclass: TBase) => class extends superclass { + /** + * handleCustomProfileFields: Handler responsible for the Create/Update operations occurring on the CUSTOM_PROFILE_FIELD table from the 'Server' schema + * @param {HandleCustomProfileFieldsArgs} fieldsArgs + * @param {CustomProfileField[]} fieldsArgs.fields + * @param {boolean} fieldsArgs.prepareRecordsOnly + * @returns {Promise} + */ + handleCustomProfileFields = async ({fields, prepareRecordsOnly = true}: HandleCustomProfileFieldsArgs): Promise => { + if (!fields?.length) { + logWarning( + 'An empty or undefined "fields" array has been passed to the handleCustomProfileFields method', + ); + return []; + } + + const createOrUpdateRawValues = getUniqueRawsBy({raws: fields, key: 'id'}); + + return this.handleRecords({ + fieldName: 'id', + transformer: transformCustomProfileFieldRecord, + createOrUpdateRawValues, + tableName: CUSTOM_PROFILE_FIELD, + prepareRecordsOnly, + }, 'handleCustomProfileFields'); + }; + + /** + * handleCustomProfileAttributes: Handler responsible for the Create/Update operations occurring on the CUSTOM_PROFILE_ATTRIBUTE table from the 'Server' schema + * @param {HandleCustomProfileAttributesArgs} attributesArgs + * @param {CustomProfileAttributeSimple[]} attributesArgs.attributes + * @param {boolean} attributesArgs.prepareRecordsOnly + * @returns {Promise} + */ + handleCustomProfileAttributes = async ({attributes, prepareRecordsOnly = true}: HandleCustomProfileAttributesArgs): Promise => { + if (!attributes?.length) { + logWarning( + 'An empty or undefined "attributes" array has been passed to the handleCustomProfileAttributes method', + ); + return []; + } + + const createOrUpdateRawValues = getUniqueRawsBy({raws: attributes, key: 'id'}); + + return this.handleRecords({ + fieldName: 'id', + transformer: transformCustomProfileAttributeRecord, + createOrUpdateRawValues, + tableName: CUSTOM_PROFILE_ATTRIBUTE, + prepareRecordsOnly, + }, 'handleCustomProfileAttributes'); + }; +}; + +export default CustomProfileHandler; diff --git a/app/database/operator/server_data_operator/handlers/user.ts b/app/database/operator/server_data_operator/handlers/user.ts index 2399980ed..d2a8235cc 100644 --- a/app/database/operator/server_data_operator/handlers/user.ts +++ b/app/database/operator/server_data_operator/handlers/user.ts @@ -123,7 +123,7 @@ const UserHandler = >(supercla const createOrUpdateRawValues = getUniqueRawsBy({raws: users, key: 'id'}); - return this.handleRecords({ + const userModels = await this.handleRecords({ fieldName: 'id', transformer: transformUserRecord, createOrUpdateRawValues, @@ -131,6 +131,8 @@ const UserHandler = >(supercla prepareRecordsOnly, shouldUpdate: shouldUpdateUserRecord, }, 'handleUsers'); + + return userModels; }; }; diff --git a/app/database/operator/server_data_operator/index.ts b/app/database/operator/server_data_operator/index.ts index a6295f6af..aa50ec602 100644 --- a/app/database/operator/server_data_operator/index.ts +++ b/app/database/operator/server_data_operator/index.ts @@ -4,6 +4,7 @@ import ServerDataOperatorBase from '@database/operator/server_data_operator/handlers'; import CategoryHandler, {type CategoryHandlerMix} from '@database/operator/server_data_operator/handlers/category'; import ChannelHandler, {type ChannelHandlerMix} from '@database/operator/server_data_operator/handlers/channel'; +import CustomProfileHandler, {type CustomProfileHandlerMix} from '@database/operator/server_data_operator/handlers/custom_profile'; import GroupHandler, {type GroupHandlerMix} from '@database/operator/server_data_operator/handlers/group'; import PostHandler, {type PostHandlerMix} from '@database/operator/server_data_operator/handlers/post'; import TeamHandler, {type TeamHandlerMix} from '@database/operator/server_data_operator/handlers/team'; @@ -17,6 +18,7 @@ import type {Database} from '@nozbe/watermelondb'; interface ServerDataOperator extends CategoryHandlerMix, ChannelHandlerMix, + CustomProfileHandlerMix, GroupHandlerMix, PostHandlerMix, ServerDataOperatorBase, @@ -29,6 +31,7 @@ interface ServerDataOperator extends class ServerDataOperator extends mix(ServerDataOperatorBase).with( CategoryHandler, ChannelHandler, + CustomProfileHandler, GroupHandler, PostHandler, TeamHandler, diff --git a/app/database/operator/server_data_operator/transformers/custom_profile.test.ts b/app/database/operator/server_data_operator/transformers/custom_profile.test.ts new file mode 100644 index 000000000..6588b160f --- /dev/null +++ b/app/database/operator/server_data_operator/transformers/custom_profile.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {OperationType, MM_TABLES} from '@constants/database'; +import {transformCustomProfileFieldRecord, transformCustomProfileAttributeRecord} from '@database/operator/server_data_operator/transformers/custom_profile'; +import {createTestConnection} from '@database/operator/utils/create_test_connection'; + +import type {CustomProfileFieldModel} from '@database/models/server'; +import type {CustomProfileAttribute} from '@typings/api/custom_profile_attributes'; + +describe('*** CUSTOM PROFILE Prepare Records Test ***', () => { + describe('=> transformCustomProfileFieldRecord', () => { + it('should return a record of type CustomProfileField', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'custom_profile_field_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecord = await transformCustomProfileFieldRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'field1', + group_id: 'group1', + name: 'Test Field', + type: 'text', + target_id: 'target1', + target_type: 'user', + create_at: 1596032651748, + update_at: 1596032651748, + delete_at: 0, + attrs: {required: true}, + }, + }, + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.collection.table).toBe(MM_TABLES.SERVER.CUSTOM_PROFILE_FIELD); + }); + + it('should handle update action', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'custom_profile_field_update_records', setActive: true}); + expect(database).toBeTruthy(); + + const mockRecord = { + id: 'field1', + groupId: 'group1', + name: 'Original Field', + type: 'text', + targetId: 'target1', + targetType: 'user', + createAt: 1596032651748, + updateAt: 1596032651748, + deleteAt: 0, + attrs: {required: true}, + _raw: {}, + _isEditing: false, + _preparedState: null, + customProfileAttributes: [], + prepare: jest.fn(), + observe: jest.fn(), + update: jest.fn(), + destroyPermanently: jest.fn(), + markAsDeleted: jest.fn(), + prepareUpdate: jest.fn().mockImplementation((callback) => { + callback(); + return mockRecord; + }), + collection: {table: 'CustomProfileField'}, + } as unknown as CustomProfileFieldModel; + + const preparedRecord = await transformCustomProfileFieldRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: mockRecord, + raw: { + id: 'field1', + group_id: 'group1', + name: 'Updated Field', + type: 'text', + target_id: 'target1', + target_type: 'user', + create_at: 1596032651748, + update_at: 1596032651749, + delete_at: 0, + attrs: {required: false}, + }, + }, + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.collection.table).toBe('CustomProfileField'); + }); + + it('should return a record of type CustomProfileAttribute', async () => { + expect.assertions(4); + + const database = await createTestConnection({databaseName: 'custom_profile_attribute_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecord = await transformCustomProfileAttributeRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'field1-user1', + field_id: 'field1', + user_id: 'user1', + value: 'Test Value', + } as CustomProfileAttribute, + }, + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.collection.table).toBe('CustomProfileAttribute'); + expect(preparedRecord!.id).toBe('field1-user1'); + }); + + it('should throw error for non-create action without record', async () => { + expect.assertions(2); + + const database = await createTestConnection({databaseName: 'custom_profile_field_error_records', setActive: true}); + expect(database).toBeTruthy(); + + try { + await transformCustomProfileFieldRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'field1', + group_id: 'group1', + name: 'Test Field', + type: 'text', + target_id: 'target1', + target_type: 'user', + create_at: 1596032651748, + update_at: 1596032651748, + delete_at: 0, + attrs: {required: true}, + }, + }, + }); + + // Should not reach here + expect(true).toBe(false); + } catch (error: any) { + expect(error.message).toBe('Record not found for non create action'); + } + }); + }); +}); diff --git a/app/database/operator/server_data_operator/transformers/custom_profile.ts b/app/database/operator/server_data_operator/transformers/custom_profile.ts new file mode 100644 index 000000000..461aa1c66 --- /dev/null +++ b/app/database/operator/server_data_operator/transformers/custom_profile.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES, OperationType} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; +import {customProfileAttributeId} from '@utils/custom_profile_attribute'; + +import type {CustomProfileAttributeModel, CustomProfileFieldModel} from '@database/models/server'; +import type {CustomProfileField, CustomProfileAttribute} from '@typings/api/custom_profile_attributes'; +import type {TransformerArgs} from '@typings/database/database'; + +const {CUSTOM_PROFILE_FIELD, CUSTOM_PROFILE_ATTRIBUTE} = MM_TABLES.SERVER; + +/** + * transformCustomProfileFieldRecord: Prepares a record of the SERVER database 'CustomProfileField' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformCustomProfileFieldRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw; + const record = value.record; + const isCreateAction = action === OperationType.CREATE; + if (!isCreateAction && !record) { + throw new Error('Record not found for non create action'); + } + + const fieldsMapper = (field: CustomProfileFieldModel) => { + field._raw.id = isCreateAction ? raw.id : record!.id; + field.groupId = raw.group_id; + field.name = raw.name; + field.type = raw.type; + field.targetId = raw.target_id; + field.targetType = raw.target_type; + field.createAt = raw.create_at; + field.updateAt = raw.update_at; + field.deleteAt = raw.delete_at; + field.attrs = raw.attrs || null; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CUSTOM_PROFILE_FIELD, + value, + fieldsMapper, + }); +}; + +/** + * transformCustomProfileAttributeRecord: Prepares a record of the SERVER database 'CustomProfileAttribute' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformCustomProfileAttributeRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as CustomProfileAttribute; + + const fieldsMapper = (attribute: CustomProfileAttributeModel) => { + attribute._raw.id = customProfileAttributeId(raw.field_id, raw.user_id); + attribute.fieldId = raw.field_id; + attribute.userId = raw.user_id; + attribute.value = raw.value; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CUSTOM_PROFILE_ATTRIBUTE, + value, + fieldsMapper, + }); +}; diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 61c1a65eb..d60b7f3f6 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -12,6 +12,8 @@ import { ChannelMembershipSchema, ConfigSchema, CustomEmojiSchema, + CustomProfileFieldSchema, + CustomProfileAttributeSchema, DraftSchema, FileSchema, GroupSchema, @@ -40,7 +42,7 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 7, + version: 8, tables: [ CategorySchema, CategoryChannelSchema, @@ -50,6 +52,8 @@ export const serverSchema: AppSchema = appSchema({ ChannelMembershipSchema, ConfigSchema, CustomEmojiSchema, + CustomProfileFieldSchema, + CustomProfileAttributeSchema, DraftSchema, FileSchema, GroupSchema, diff --git a/app/database/schema/server/table_schemas/custom_profile_attribute.ts b/app/database/schema/server/table_schemas/custom_profile_attribute.ts new file mode 100644 index 000000000..954f831b2 --- /dev/null +++ b/app/database/schema/server/table_schemas/custom_profile_attribute.ts @@ -0,0 +1,17 @@ +// 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 {CUSTOM_PROFILE_ATTRIBUTE} = MM_TABLES.SERVER; + +export default tableSchema({ + name: CUSTOM_PROFILE_ATTRIBUTE, + columns: [ + {name: 'field_id', type: 'string', isIndexed: true}, + {name: 'user_id', type: 'string', isIndexed: true}, + {name: 'value', type: 'string'}, + ], +}); diff --git a/app/database/schema/server/table_schemas/custom_profile_field.ts b/app/database/schema/server/table_schemas/custom_profile_field.ts new file mode 100644 index 000000000..ff3dc5916 --- /dev/null +++ b/app/database/schema/server/table_schemas/custom_profile_field.ts @@ -0,0 +1,23 @@ +// 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 {CUSTOM_PROFILE_FIELD} = MM_TABLES.SERVER; + +export default tableSchema({ + name: CUSTOM_PROFILE_FIELD, + columns: [ + {name: 'group_id', type: 'string'}, + {name: 'name', type: 'string'}, + {name: 'type', type: 'string'}, + {name: 'target_id', type: 'string'}, + {name: 'target_type', type: 'string'}, + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'attrs', type: 'string', isOptional: true}, + ], +}); diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index ae6337034..f549b54ad 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -8,6 +8,8 @@ export {default as ChannelBookmarkSchema} from './channel_bookmark'; export {default as ChannelInfoSchema} from './channel_info'; export {default as ChannelMembershipSchema} from './channel_membership'; export {default as CustomEmojiSchema} from './custom_emoji'; +export {default as CustomProfileFieldSchema} from './custom_profile_field'; +export {default as CustomProfileAttributeSchema} from './custom_profile_attribute'; export {default as DraftSchema} from './draft'; export {default as FileSchema} from './file'; export {default as GroupSchema} from './group'; diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index a3ebdd532..60cbd5594 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -16,6 +16,8 @@ const { CHANNEL_MEMBERSHIP, CONFIG, CUSTOM_EMOJI, + CUSTOM_PROFILE_FIELD, + CUSTOM_PROFILE_ATTRIBUTE, DRAFT, FILE, GROUP, @@ -46,7 +48,7 @@ const { describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 7, + version: 8, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -208,6 +210,46 @@ describe('*** Test schema for SERVER database ***', () => { }, columnArray: [{name: 'name', type: 'string', isIndexed: true}], }, + [CUSTOM_PROFILE_FIELD]: { + name: CUSTOM_PROFILE_FIELD, + unsafeSql: undefined, + columns: { + group_id: {name: 'group_id', type: 'string'}, + name: {name: 'name', type: 'string'}, + type: {name: 'type', type: 'string'}, + target_id: {name: 'target_id', type: 'string'}, + target_type: {name: 'target_type', type: 'string'}, + create_at: {name: 'create_at', type: 'number'}, + update_at: {name: 'update_at', type: 'number'}, + delete_at: {name: 'delete_at', type: 'number'}, + attrs: {name: 'attrs', type: 'string', isOptional: true}, + }, + columnArray: [ + {name: 'group_id', type: 'string'}, + {name: 'name', type: 'string'}, + {name: 'type', type: 'string'}, + {name: 'target_id', type: 'string'}, + {name: 'target_type', type: 'string'}, + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'attrs', type: 'string', isOptional: true}, + ], + }, + [CUSTOM_PROFILE_ATTRIBUTE]: { + name: CUSTOM_PROFILE_ATTRIBUTE, + unsafeSql: undefined, + columns: { + field_id: {name: 'field_id', type: 'string', isIndexed: true}, + user_id: {name: 'user_id', type: 'string', isIndexed: true}, + value: {name: 'value', type: 'string'}, + }, + columnArray: [ + {name: 'field_id', type: 'string', isIndexed: true}, + {name: 'user_id', type: 'string', isIndexed: true}, + {name: 'value', type: 'string'}, + ], + }, [MY_CHANNEL]: { name: MY_CHANNEL, unsafeSql: undefined, diff --git a/app/queries/servers/custom_profile.test.ts b/app/queries/servers/custom_profile.test.ts new file mode 100644 index 000000000..cb0094ca8 --- /dev/null +++ b/app/queries/servers/custom_profile.test.ts @@ -0,0 +1,790 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +/* eslint-disable max-lines */ + +import {random} from 'lodash'; + +import DatabaseManager from '@database/manager'; +import {customProfileAttributeId} from '@utils/custom_profile_attribute'; + +import { + getCustomProfileFieldById, + observeCustomProfileFields, + getCustomProfileAttribute, + observeCustomProfileAttribute, + observeCustomProfileAttributesByUserId, + convertProfileAttributesToCustomAttributes, + queryCustomProfileFields, + queryCustomProfileAttributesByUserId, + queryCustomProfileAttributesByFieldId, + deleteCustomProfileAttributesByFieldId, +} from './custom_profile'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; +import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; + +describe('Custom Profile Queries', () => { + const serverUrl = 'custom-profile.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('getCustomProfileFieldById', () => { + it('should return custom profile field by id', async () => { + const fieldId = 'field1'; + await operator.handleCustomProfileFields({ + fields: [{ + id: fieldId, + name: 'Test Field', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }], + prepareRecordsOnly: false, + }); + + const field = await getCustomProfileFieldById(database, fieldId); + expect(field).not.toBeUndefined(); + expect(field?.name).toBe('Test Field'); + }); + + it('should return undefined for non-existent field', async () => { + const field = await getCustomProfileFieldById(database, 'nonexistent'); + expect(field).toBeUndefined(); + }); + }); + + describe('observeCustomProfileFields', () => { + it('should observe all custom profile fields', (done) => { + const errorFn = jest.fn(); + + operator.handleCustomProfileFields({ + fields: [ + { + id: 'field1', + name: 'Test Field 1', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + { + id: 'field2', + name: 'Test Field 2', + type: 'select', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }).then(() => { + observeCustomProfileFields(database).subscribe({ + next: (fields) => { + expect(fields.length).toBe(2); + expect(fields[0].name).toBe('Test Field 1'); + expect(fields[1].name).toBe('Test Field 2'); + done(); + }, + error: errorFn, + }); + }); + expect(errorFn).toHaveBeenCalledTimes(0); + }); + }); + + describe('getCustomProfileAttribute', () => { + it('should return custom profile attribute by field id and user id', async () => { + const fieldId = 'field1'; + const userId = 'user1'; + + await operator.handleCustomProfileFields({ + fields: [{ + id: fieldId, + name: 'Test Field', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }], + prepareRecordsOnly: false, + }); + + await operator.handleCustomProfileAttributes({ + attributes: [{ + id: `${fieldId}_${userId}`, + field_id: fieldId, + user_id: userId, + value: 'Test Value', + }], + prepareRecordsOnly: false, + }); + + const attribute = await getCustomProfileAttribute(database, fieldId, userId); + expect(attribute).not.toBeUndefined(); + expect(attribute?.value).toBe('Test Value'); + }); + + it('should return undefined for non-existent attribute', async () => { + const attribute = await getCustomProfileAttribute(database, 'nonexistent', 'nonexistent'); + expect(attribute).toBeUndefined(); + }); + }); + + describe('observeCustomProfileAttribute', () => { + it('should observe a custom profile attribute', (done) => { + const fieldId = 'field1'; + const userId = 'user1'; + + Promise.all([ + operator.handleCustomProfileFields({ + fields: [{ + id: fieldId, + name: 'Test Field', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }], + prepareRecordsOnly: false, + }), + operator.handleCustomProfileAttributes({ + attributes: [{ + id: `${fieldId}_${userId}`, + field_id: fieldId, + user_id: userId, + value: 'Test Value', + }], + prepareRecordsOnly: false, + }), + ]).then(() => { + observeCustomProfileAttribute(database, fieldId, userId).subscribe({ + next: (attribute) => { + expect(attribute).not.toBeUndefined(); + expect(attribute?.value).toBe('Test Value'); + done(); + }, + error: done, + }); + }); + }); + + it('should return undefined for non-existent attribute', (done) => { + observeCustomProfileAttribute(database, 'nonexistent', 'nonexistent').subscribe({ + next: (attribute) => { + expect(attribute).toBeUndefined(); + done(); + }, + error: done, + }); + }); + }); + + describe('observeCustomProfileAttributesByUserId', () => { + it('should observe all custom profile attributes for a user', (done) => { + const userId = 'user1'; + + Promise.all([ + operator.handleCustomProfileFields({ + fields: [ + { + id: 'field1', + name: 'Test Field 1', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + { + id: 'field2', + name: 'Test Field 2', + type: 'select', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }), + operator.handleCustomProfileAttributes({ + attributes: [ + { + id: `field1_${userId}`, + field_id: 'field1', + user_id: userId, + value: 'Value 1', + }, + { + id: `field2_${userId}`, + field_id: 'field2', + user_id: userId, + value: 'Value 2', + }, + ], + prepareRecordsOnly: false, + }), + ]).then(() => { + observeCustomProfileAttributesByUserId(database, userId).subscribe({ + next: (attributes) => { + expect(attributes.length).toBe(2); + if (attributes[0].id === 'field1_user1') { + expect(attributes[0].value).toBe('Value 1'); + expect(attributes[1].value).toBe('Value 2'); + } else if (attributes[0].id === 'field2_user1') { + expect(attributes[0].value).toBe('Value 2'); + expect(attributes[1].value).toBe('Value 1'); + } + done(); + }, + error: done, + }); + }); + }); + + it('should return empty array for non-existent user', (done) => { + observeCustomProfileAttributesByUserId(database, 'nonexistent').subscribe({ + next: (attributes) => { + expect(attributes.length).toBe(0); + done(); + }, + error: done, + }); + }); + }); + + describe('convertProfileAttributesToCustomAttributes', () => { + it('should convert profile attributes to custom attributes', async () => { + const fieldId1 = 'field1'; + const fieldId2 = 'field2'; + const userId = 'user1'; + + await operator.handleCustomProfileFields({ + fields: [ + { + id: fieldId1, + name: 'Test Field 1', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + attrs: {sort_order: 1}, + }, + { + id: fieldId2, + name: 'Test Field 2', + type: 'select', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + attrs: {sort_order: 0}, + }, + ], + prepareRecordsOnly: false, + }); + + await operator.handleCustomProfileAttributes({ + attributes: [ + { + id: `${fieldId1}_${userId}`, + field_id: fieldId1, + user_id: userId, + value: 'Value 1', + }, + { + id: `${fieldId2}_${userId}`, + field_id: fieldId2, + user_id: userId, + value: 'Value 2', + }, + ], + prepareRecordsOnly: false, + }); + + const attributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); + const customAttributes = await convertProfileAttributesToCustomAttributes(database, attributes); + + expect(customAttributes.length).toBe(2); + if (customAttributes[0].id === fieldId1) { + expect(customAttributes[0].id).toBe(fieldId1); + expect(customAttributes[0].name).toBe('Test Field 1'); + expect(customAttributes[0].value).toBe('Value 1'); + expect(customAttributes[0].sort_order).toBe(1); + + expect(customAttributes[1].id).toBe(fieldId2); + expect(customAttributes[1].name).toBe('Test Field 2'); + expect(customAttributes[1].value).toBe('Value 2'); + expect(customAttributes[1].sort_order).toBe(0); + } else { + expect(customAttributes[0].id).toBe(fieldId2); + expect(customAttributes[0].name).toBe('Test Field 2'); + expect(customAttributes[0].value).toBe('Value 2'); + expect(customAttributes[0].sort_order).toBe(0); + + expect(customAttributes[1].id).toBe(fieldId1); + expect(customAttributes[1].name).toBe('Test Field 1'); + expect(customAttributes[1].value).toBe('Value 1'); + expect(customAttributes[1].sort_order).toBe(1); + } + }); + + it('should sort custom attributes by custom sort function', async () => { + const fieldId1 = 'field1'; + const fieldId2 = 'field2'; + const userId = 'user1'; + + await operator.handleCustomProfileFields({ + fields: [ + { + id: fieldId1, + name: 'Test Field 1', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + attrs: {sort_order: 1}, + }, + { + id: fieldId2, + name: 'Test Field 2', + type: 'select', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + attrs: {sort_order: 0}, + }, + ], + prepareRecordsOnly: false, + }); + + await operator.handleCustomProfileAttributes({ + attributes: [ + { + id: `${fieldId1}_${userId}`, + field_id: fieldId1, + user_id: userId, + value: 'Value 1', + }, + { + id: `${fieldId2}_${userId}`, + field_id: fieldId2, + user_id: userId, + value: 'Value 2', + }, + ], + prepareRecordsOnly: false, + }); + + const attributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); + + // Sort by sort_order in ascending order + const customAttributes = await convertProfileAttributesToCustomAttributes( + database, + attributes, + (a, b) => (a.sort_order || 0) - (b.sort_order || 0), + ); + + expect(customAttributes.length).toBe(2); + expect(customAttributes[0].id).toBe(fieldId2); // This has sort_order 0 + expect(customAttributes[1].id).toBe(fieldId1); // This has sort_order 1 + }); + + it('should handle empty attributes array', async () => { + const customAttributes = await convertProfileAttributesToCustomAttributes(database, []); + expect(customAttributes).toEqual([]); + }); + + it('should handle null attributes', async () => { + const customAttributes = await convertProfileAttributesToCustomAttributes(database, null); + expect(customAttributes).toEqual([]); + }); + }); + + describe('queryCustomProfileFields', () => { + it('should query all custom profile fields', async () => { + await operator.handleCustomProfileFields({ + fields: [ + { + id: 'field1', + name: 'Test Field 1', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + { + id: 'field2', + name: 'Test Field 2', + type: 'select', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }); + + const fields = await queryCustomProfileFields(database).fetch(); + expect(fields.length).toBe(2); + }); + }); + + describe('queryCustomProfileAttributesByUserId', () => { + it('should query all attributes for a user', async () => { + const userId = 'user1'; + + await operator.handleCustomProfileFields({ + fields: [ + { + id: 'field1', + name: 'Test Field 1', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + { + id: 'field2', + name: 'Test Field 2', + type: 'select', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }); + + await operator.handleCustomProfileAttributes({ + attributes: [ + { + id: `field1_${userId}`, + field_id: 'field1', + user_id: userId, + value: 'Value 1', + }, + { + id: `field2_${userId}`, + field_id: 'field2', + user_id: userId, + value: 'Value 2', + }, + { + id: 'field1_user2', + field_id: 'field1', + user_id: 'user2', + value: 'Another value', + }, + ], + prepareRecordsOnly: false, + }); + + const attributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); + expect(attributes.length).toBe(2); + if (attributes[0].id === 'field1_user1') { + expect(attributes[0].fieldId).toBe('field1'); + expect(attributes[1].fieldId).toBe('field2'); + } else if (attributes[0].id === 'field2_user1') { + expect(attributes[0].fieldId).toBe('field2'); + expect(attributes[1].fieldId).toBe('field1'); + } + }); + }); + + describe('queryCustomProfileAttributesByFieldId', () => { + it('should query all attributes for a field', async () => { + const fieldId = 'field1'; + + await operator.handleCustomProfileFields({ + fields: [ + { + id: fieldId, + name: 'Test Field', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }); + + await operator.handleCustomProfileAttributes({ + attributes: [ + { + id: `${fieldId}_user1`, + field_id: fieldId, + user_id: 'user1', + value: 'Value 1', + }, + { + id: `${fieldId}_user2`, + field_id: fieldId, + user_id: 'user2', + value: 'Value 2', + }, + { + id: 'field2_user1', + field_id: 'field2', + user_id: 'user1', + value: 'Another value', + }, + ], + prepareRecordsOnly: false, + }); + + const attributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + expect(attributes.length).toBe(2); + if (attributes[0].id === 'field1_user1') { + expect(attributes[0].userId).toBe('user1'); + expect(attributes[1].userId).toBe('user2'); + } else if (attributes[0].id === 'field2_user1') { + expect(attributes[0].userId).toBe('user2'); + expect(attributes[1].userId).toBe('user1'); + } + }); + }); + + describe('deleteCustomProfileAttributesByFieldId', () => { + it('should delete all attributes for a field', async () => { + const fieldId = 'field1'; + + await operator.handleCustomProfileFields({ + fields: [ + { + id: fieldId, + name: 'Test Field', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }); + + await operator.handleCustomProfileAttributes({ + attributes: [ + { + id: `${fieldId}_user1`, + field_id: fieldId, + user_id: 'user1', + value: 'Value 1', + }, + { + id: `${fieldId}_user2`, + field_id: fieldId, + user_id: 'user2', + value: 'Value 2', + }, + { + id: 'field2_user1', + field_id: 'field2', + user_id: 'user1', + value: 'Another value', + }, + ], + prepareRecordsOnly: false, + }); + + // Check initial state + let field1Attributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + let field2Attributes = await queryCustomProfileAttributesByFieldId(database, 'field2').fetch(); + expect(field1Attributes.length).toBe(2); + expect(field2Attributes.length).toBe(1); + + // Delete attributes for field1 + await deleteCustomProfileAttributesByFieldId(database, fieldId); + + // Verify deletion + field1Attributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + field2Attributes = await queryCustomProfileAttributesByFieldId(database, 'field2').fetch(); + expect(field1Attributes.length).toBe(0); + expect(field2Attributes.length).toBe(1); + }); + + it('should delete attributes with custom batch size', async () => { + const fieldId = 'field3'; + const attributeCount = 5; + + // Create a field + await operator.handleCustomProfileFields({ + fields: [ + { + id: fieldId, + name: 'Batch Test Field', + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + }, + ], + prepareRecordsOnly: false, + }); + + // Create multiple attributes for testing batch deletion + const attributes = Array.from({length: attributeCount}, (_, i) => ({ + id: `${fieldId}_user${i}`, + field_id: fieldId, + user_id: `user${i}`, + value: `Value ${i}`, + })); + + await operator.handleCustomProfileAttributes({ + attributes, + prepareRecordsOnly: false, + }); + + // Verify initial state + let fieldAttributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + expect(fieldAttributes.length).toBe(attributeCount); + + // Delete with custom batch size (smaller than total count) + await deleteCustomProfileAttributesByFieldId(database, fieldId, 2); + + // Verify all attributes were deleted despite the small batch size + fieldAttributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + expect(fieldAttributes.length).toBe(0); + }); + + it('should handle case when no attributes exist', async () => { + const nonExistentFieldId = 'nonexistent_field'; + + // Try to delete attributes for a field that doesn't exist + await deleteCustomProfileAttributesByFieldId(database, nonExistentFieldId); + + // Function should complete without errors + expect(true).toBe(true); + }); + }); + + describe('Performance Tests', () => { + it('should profile convertProfileAttributesToCustomAttributes performance', async () => { + jest.setTimeout(30000); // Increase timeout for this test + + // Create a larger dataset to test performance + const fieldCount = 200; + const userCount = 5; + + // Create fields + const fields = Array.from({length: fieldCount}, (_, i) => ({ + id: `field${i}`, + name: `Test Field ${i}`, + type: 'text', + delete_at: 0, + group_id: '', + target_id: '', + target_type: 'user', + create_at: 1000, + update_at: 1000, + attrs: {sort_order: i + random(0, 1000)}, + })); + + await operator.handleCustomProfileFields({ + fields, + prepareRecordsOnly: false, + }); + + // Create attributes (10 attributes per user) + const attributes = []; + for (let u = 0; u < userCount; u++) { + const userId = `user${u}`; + for (let f = 0; f < fieldCount; f++) { + const fieldId = `field${f}`; + attributes.push({ + id: customProfileAttributeId(fieldId, userId), + field_id: fieldId, + user_id: userId, + value: `Value for user ${u} field ${f}`, + }); + } + } + + await operator.handleCustomProfileAttributes({ + attributes, + prepareRecordsOnly: false, + }); + + // Profile the function for one specific user + const userId = 'user0'; + const userAttributes = await queryCustomProfileAttributesByUserId(database, userId).fetch(); + + console.log(`Testing conversion of ${userAttributes.length} attributes`); + + // Run the function once to get performance data + const sortFn = (a: CustomAttribute, b: CustomAttribute) => (a.sort_order || 0) - (b.sort_order || 0); + + const startTime = performance.now(); + await convertProfileAttributesToCustomAttributes(database, userAttributes, sortFn); + const endTime = performance.now(); + console.log(`Time with sorting: ${(endTime - startTime).toFixed(2)}ms`); + + expect(true).toBe(true); // No assertions needed for profiling + }); + }); +}); diff --git a/app/queries/servers/custom_profile.ts b/app/queries/servers/custom_profile.ts new file mode 100644 index 000000000..15b211d1f --- /dev/null +++ b/app/queries/servers/custom_profile.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$, type Observable} from 'rxjs'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; + +import {MM_TABLES} from '@constants/database'; +import {customProfileAttributeId} from '@utils/custom_profile_attribute'; + +import type {CustomAttribute} from '@typings/api/custom_profile_attributes'; +import type CustomProfileAttributeModel from 'app/database/models/server/custom_profile_attribute'; +import type CustomProfileFieldModel from 'app/database/models/server/custom_profile_field'; + +const {CUSTOM_PROFILE_FIELD, CUSTOM_PROFILE_ATTRIBUTE} = MM_TABLES.SERVER; + +/** + * Get a custom profile field by ID + * @param database - The database instance + * @param fieldId - The field ID + * @returns The custom profile field or undefined + */ +export const getCustomProfileFieldById = async (database: Database, fieldId: string) => { + try { + const fieldRecord = await database.get(CUSTOM_PROFILE_FIELD).find(fieldId); + return fieldRecord; + } catch { + return undefined; + } +}; + +/** + * Observe a custom profile field + * @param database - The database instance + * @returns Observable of the custom profile field + */ +export const observeCustomProfileFields = (database: Database) => { + return queryCustomProfileFields(database).observeWithColumns(['update_at', 'delete_at']).pipe( + distinctUntilChanged(), + ); +}; + +/** + * Get a custom profile attribute by field ID and user ID + * @param database - The database instance + * @param fieldId - The field ID + * @param userId - The user ID + * @returns The custom profile attribute or undefined + */ +export const getCustomProfileAttribute = async (database: Database, fieldId: string, userId: string) => { + try { + const attributeRecord = await database.get(CUSTOM_PROFILE_ATTRIBUTE).query( + Q.where('id', customProfileAttributeId(fieldId, userId)), + Q.take(1), + ).fetch(); + return attributeRecord[0]; + } catch { + return undefined; + } +}; + +/** + * Observe a custom profile attribute + * @param database - The database instance + * @param fieldId - The field ID + * @param userId - The user ID + * @returns Observable of the custom profile attribute + */ +export const observeCustomProfileAttribute = (database: Database, fieldId: string, userId: string) => { + return database.get(CUSTOM_PROFILE_ATTRIBUTE).query( + Q.where('id', customProfileAttributeId(fieldId, userId)), + Q.take(1), + ).observe().pipe( + switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), + ); +}; + +/** + * Observe all custom profile attributes for a user + * @param database - The database instance + * @param userId - The user ID + * @returns Observable of the custom profile attributes + */ +export const observeCustomProfileAttributesByUserId = (database: Database, userId: string): Observable => { + return queryCustomProfileAttributesByUserId(database, userId).observeWithColumns(['value']).pipe(distinctUntilChanged()); +}; + +/** + * Convert custom profile attributes to the UI-ready CustomAttribute format + * @param database - The database instance + * @param attributes - Array of custom profile attribute models + * @returns Promise resolving to array of formatted CustomAttribute objects + */ +export const convertProfileAttributesToCustomAttributes = async ( + database: Database, + attributes: CustomProfileAttributeModel[] | null | undefined, + sortFn?: (a: CustomAttribute, b: CustomAttribute) => number, +): Promise => { + if (!attributes?.length) { + return []; + } + + // We need to fetch field details for names and sorting + const fieldIds = attributes.map((attr) => attr.fieldId); + const fieldsQuery = await database.get(CUSTOM_PROFILE_FIELD).query( + Q.where('id', Q.oneOf(fieldIds)), + ).fetch(); + + // Create a map of field IDs to names + const fieldsMap = new Map(); + fieldsQuery.forEach((field) => { + fieldsMap.set(field.id, { + name: field.name, + sort_order: field.attrs?.sort_order || 0, + }); + }); + + // Convert DB attributes to CustomAttribute format + const customAttrs = attributes.map((attr) => ({ + id: attr.fieldId, + name: fieldsMap.get(attr.fieldId)?.name || attr.fieldId, + value: attr.value, + sort_order: fieldsMap.get(attr.fieldId)?.sort_order || 0, + })); + + // Sort if a sort function is provided + return sortFn ? customAttrs.sort(sortFn) : customAttrs; +}; + +/** + * Query to fetch all custom profile fields + * @param database - The database instance + * @returns Query for custom profile fields + */ +export const queryCustomProfileFields = (database: Database) => { + return database.get(CUSTOM_PROFILE_FIELD).query(Q.where('delete_at', 0)); +}; + +/** + * Query to fetch custom profile attributes for a user + * @param database - The database instance + * @param userId - The user ID + * @returns Query for custom profile attributes + */ +export const queryCustomProfileAttributesByUserId = (database: Database, userId: string) => { + return database.get(CUSTOM_PROFILE_ATTRIBUTE).query( + Q.where('user_id', userId), + ); +}; + +/** + * Query to fetch custom profile attributes by field ID + * @param database - The database instance + * @param fieldId - The field ID + * @returns Query for custom profile attributes + */ +export const queryCustomProfileAttributesByFieldId = (database: Database, fieldId: string) => { + return database.get(CUSTOM_PROFILE_ATTRIBUTE).query( + Q.where('field_id', fieldId), + ); +}; + +/** + * Delete custom profile attributes by field ID with batching support + * @param database - The database instance + * @param fieldId - The field ID + * @param batchSize - Number of records to delete in each batch (defaults to 100) + * @returns Promise that resolves when the deletion is complete + */ +export const deleteCustomProfileAttributesByFieldId = async (database: Database, fieldId: string, batchSize = 100) => { + const attributes = await queryCustomProfileAttributesByFieldId(database, fieldId).fetch(); + + if (!attributes.length) { + return; + } + + // Process attributes in batches to avoid performance issues with large datasets + const promises = []; + for (let i = 0; i < attributes.length; i += batchSize) { + const batch = attributes.slice(i, i + batchSize); + const preparedModels = batch.map((attribute) => attribute.prepareDestroyPermanently()); + + const batchPromise = database.write(async () => { + await database.batch(...preparedModels); + }, `deleteCustomProfileAttributesByFieldId:${fieldId}:batch:${i}`); + + promises.push(batchPromise); + } + + await Promise.all(promises); +}; + diff --git a/app/utils/custom_profile_attribute.ts b/app/utils/custom_profile_attribute.ts new file mode 100644 index 000000000..f24e14fcf --- /dev/null +++ b/app/utils/custom_profile_attribute.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const customProfileAttributeId = (fieldId: string, userId: string) => `${fieldId}-${userId}`; diff --git a/docs/database/server/server.md b/docs/database/server/server.md index a5277a7f8..9be88563f 100644 --- a/docs/database/server/server.md +++ b/docs/database/server/server.md @@ -1,4 +1,4 @@ -# Server Database - Schema Version 4 +# Server Database - Schema Version 5 # Please bump the version by 1, any time the schema changes. # Also, include the migration plan under app/database/migration/server, # update all models, relationships and types. @@ -65,6 +65,28 @@ id PK string # auto-generated name string +CustomProfileField +- +id PK string # server-generated +group_id string +name string +type string +target_id string +target_type string +create_at number +update_at number +delete_at number +attrs string NULL # stringified JSON + + +CustomProfileAttribute +- +id PK string # composition ID User.id-CustomProfileField.id +field_id string INDEX FK >- CustomProfileField.id +user_id string INDEX FK >- User.id +value string + + Draft - id PK string # auto-generated diff --git a/test/test_helper.ts b/test/test_helper.ts index 5f7c35678..287b1be0d 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -549,6 +549,7 @@ class TestHelperSingleton { channelsCreated: this.fakeQuery([]), channels: this.fakeQuery([]), + customProfileAttributes: this.fakeQuery([]), posts: this.fakeQuery([]), preferences: this.fakeQuery([]), reactions: this.fakeQuery([]), diff --git a/types/api/custom_profile_attributes.d.ts b/types/api/custom_profile_attributes.d.ts index e553027d4..2b48056ad 100644 --- a/types/api/custom_profile_attributes.d.ts +++ b/types/api/custom_profile_attributes.d.ts @@ -37,9 +37,28 @@ type CustomProfileField = { /** * CustomProfileAttributeSimple - * @description simpler type to display a field id with its value. + * @description Type representing a custom profile attribute with its field ID, user ID, and value. **/ -type CustomProfileAttributeSimple = { +type CustomProfileAttribute = { + + /** ID of the custom profile attribute */ + id: string; + + /** ID of the custom profile field this attribute is for */ + field_id: string; + + /** ID of the user this attribute belongs to */ + user_id: string; + + /** Value of the attribute */ + value: string; +} + +/** + * UserCustomProfileAttributeSimple + * @description simpler type to display a field id with its value, when we already know it all belongs to the same user + **/ +type UserCustomProfileAttributeSimple = { [field_id: string]: string; } diff --git a/types/database/database.ts b/types/database/database.ts index 648ef54ed..75bf145e8 100644 --- a/types/database/database.ts +++ b/types/database/database.ts @@ -10,6 +10,7 @@ import type {Database} from '@nozbe/watermelondb'; import type Model from '@nozbe/watermelondb/Model'; import type {Clause} from '@nozbe/watermelondb/QueryDescription'; import type {Class} from '@nozbe/watermelondb/types'; +import type {CustomProfileField, CustomProfileAttribute} from '@typings/api/custom_profile_attributes'; import type System from '@typings/database/models/servers/system'; export type WithDatabaseArgs = { database: Database } @@ -294,6 +295,14 @@ export type HandleDraftArgs = PrepareOnly & { drafts?: Draft[]; }; +export type HandleCustomProfileFieldsArgs = PrepareOnly & { + fields?: CustomProfileField[]; +}; + +export type HandleCustomProfileAttributesArgs = PrepareOnly & { + attributes?: CustomProfileAttribute[]; +}; + export type LoginArgs = { config: Partial; ldapOnly?: boolean; diff --git a/types/database/models/servers/custom_profile_attribute.ts b/types/database/models/servers/custom_profile_attribute.ts new file mode 100644 index 000000000..2076f79cf --- /dev/null +++ b/types/database/models/servers/custom_profile_attribute.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type CustomProfileFieldModel from './custom_profile_field'; +import type UserModel from './user'; +import type {Relation, Model} from '@nozbe/watermelondb'; +import type {Associations} from '@nozbe/watermelondb/Model'; + +declare class CustomProfileAttributeModel extends Model { + /** table (name) : CustomProfileAttribute */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** field_id : The identifier of the custom profile field this attribute is for */ + fieldId: string; + + /** user_id : The identifier of the user this attribute belongs to */ + userId: string; + + /** value : The value of the custom profile attribute */ + value: string; + + /** Relation to the custom profile field */ + field: Relation; + + /** Relation to the user */ + user: Relation; +} + +export default CustomProfileAttributeModel; diff --git a/types/database/models/servers/custom_profile_field.ts b/types/database/models/servers/custom_profile_field.ts new file mode 100644 index 000000000..e994295aa --- /dev/null +++ b/types/database/models/servers/custom_profile_field.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type CustomProfileAttributeModel from './custom_profile_attribute'; +import type {Model, Query} from '@nozbe/watermelondb'; +import type {Associations} from '@nozbe/watermelondb/Model'; + +export type CustomProfileFieldAttrs = { + sort_order?: number; + [key: string]: unknown; +}; + +declare class CustomProfileFieldModel extends Model { + static table: string; + static associations: Associations; + + /** group_id : The identifier of the group this field belongs to */ + groupId: string; + + /** name : The name of the custom profile field */ + name: string; + + /** type : The type of values accepted (e.g., 'text') */ + type: string; + + /** target_id : The id of the target element (empty if system property) */ + targetId: string; + + /** target_type : The type of element this is assigned to (e.g., 'user', 'post', 'card') */ + targetType: string; + + /** create_at : The timestamp when this field was created */ + createAt: number; + + /** update_at : The timestamp when this field was last updated */ + updateAt: number; + + /** delete_at : The timestamp when this field was deleted (0 if not deleted) */ + deleteAt: number; + + /** attrs : Any extra properties of the field */ + attrs: CustomProfileFieldAttrs | null; + + /** customProfileAttributes : All the custom profile attributes for this field */ + customProfileAttributes: Query; +} + +export default CustomProfileFieldModel; diff --git a/types/database/models/servers/user.ts b/types/database/models/servers/user.ts index 604c32806..0a1792c3b 100644 --- a/types/database/models/servers/user.ts +++ b/types/database/models/servers/user.ts @@ -3,6 +3,7 @@ import type ChannelModel from './channel'; import type ChannelMembershipModel from './channel_membership'; +import type CustomProfileAttributeModel from './custom_profile_attribute'; import type PostModel from './post'; import type PreferenceModel from './preference'; import type ReactionModel from './reaction'; @@ -121,6 +122,9 @@ declare class UserModel extends Model { /** termsOfServiceCreateAt : The last time the user accepted the terms of service */ termsOfServiceCreateAt: number; + + /** customProfileAttributes : All the custom profile attributes for this user */ + customProfileAttributes: Query | undefined; } export default UserModel; diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index 332f76a05..4681758d3 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -137,4 +137,6 @@ type RawValue = | TeamThreadsSync | UserProfile | SlashCommand + | CustomProfileAttribute + | CustomProfileField | Pick