[MM-62565] DB only custom profile attributes and fields (#8739)

Database only
This commit is contained in:
Guillermo Vayá 2025-04-09 15:35:27 +02:00 committed by GitHub
parent 74ee271012
commit 6a060cd505
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1952 additions and 18 deletions

View file

@ -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;
});

View file

@ -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<CustomProfileField[]>;
getCustomProfileAttributeValues: (userID: string) => Promise<CustomProfileAttributeSimple>;
updateCustomProfileAttributeValues: (values: CustomProfileAttributeSimple) => Promise<string>;
getCustomProfileAttributeValues: (userID: string) => Promise<UserCustomProfileAttributeSimple>;
updateCustomProfileAttributeValues: (values: UserCustomProfileAttributeSimple) => Promise<string>;
}
const ClientCustomAttributes = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -24,7 +24,7 @@ const ClientCustomAttributes = <TBase extends Constructor<ClientBase>>(superclas
{method: 'get'},
);
};
updateCustomProfileAttributeValues = async (values: CustomProfileAttributeSimple) => {
updateCustomProfileAttributeValues = async (values: UserCustomProfileAttributeSimple) => {
return this.doFetch(
`${this.getCustomProfileAttributesRoute()}/values`,
{

View file

@ -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',

View file

@ -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,

View file

@ -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,

View file

@ -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: [

View file

@ -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<CustomProfileFieldModel>;
/** Relation to the user */
@immutableRelation(USER, 'user_id') user!: Relation<UserModel>;
}

View file

@ -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<CustomProfileAttributeModel>;
}

View file

@ -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';

View file

@ -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<ThreadParticipantsModel>;
/** USER has a 1:N relationship with CUSTOM_PROFILE_ATTRIBUTE. A user can have multiple custom profile attributes */
@children(CUSTOM_PROFILE_ATTRIBUTE) customProfileAttributes!: Query<CustomProfileAttributeModel> | undefined;
prepareStatus = (status: string) => {
this.prepareUpdate((u) => {
u.status = status;

View file

@ -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];

View file

@ -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',
);
});
});
});

View file

@ -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<Model[]>;
handleCustomProfileAttributes: ({attributes, prepareRecordsOnly}: HandleCustomProfileAttributesArgs) => Promise<Model[]>;
}
const CustomProfileHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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<Model[]>}
*/
handleCustomProfileFields = async ({fields, prepareRecordsOnly = true}: HandleCustomProfileFieldsArgs): Promise<Model[]> => {
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<Model[]>}
*/
handleCustomProfileAttributes = async ({attributes, prepareRecordsOnly = true}: HandleCustomProfileAttributesArgs): Promise<Model[]> => {
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;

View file

@ -123,7 +123,7 @@ const UserHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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 = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
prepareRecordsOnly,
shouldUpdate: shouldUpdateUserRecord,
}, 'handleUsers');
return userModels;
};
};

View file

@ -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,

View file

@ -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');
}
});
});
});

View file

@ -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<CustomProfileFieldModel, CustomProfileField>} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<CustomProfileFieldModel>}
*/
export const transformCustomProfileFieldRecord = ({action, database, value}: TransformerArgs<CustomProfileFieldModel, CustomProfileField>): Promise<CustomProfileFieldModel> => {
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<CustomProfileAttributeModel, CustomProfileAttribute>} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<CustomProfileAttributeModel>}
*/
export const transformCustomProfileAttributeRecord = ({action, database, value}: TransformerArgs<CustomProfileAttributeModel, CustomProfileAttribute>): Promise<CustomProfileAttributeModel> => {
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,
});
};

View file

@ -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,

View file

@ -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'},
],
});

View file

@ -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},
],
});

View file

@ -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';

View file

@ -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,

View file

@ -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
});
});
});

View file

@ -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<CustomProfileFieldModel>(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<CustomProfileAttributeModel>(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<CustomProfileAttributeModel>(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<CustomProfileAttributeModel[]> => {
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<CustomAttribute[]> => {
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<CustomProfileFieldModel>(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<CustomProfileFieldModel>(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<CustomProfileAttributeModel>(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<CustomProfileAttributeModel>(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);
};

View file

@ -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}`;

View file

@ -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

View file

@ -549,6 +549,7 @@ class TestHelperSingleton {
channelsCreated: this.fakeQuery([]),
channels: this.fakeQuery([]),
customProfileAttributes: this.fakeQuery([]),
posts: this.fakeQuery([]),
preferences: this.fakeQuery([]),
reactions: this.fakeQuery([]),

View file

@ -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;
}

View file

@ -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<ClientConfig>;
ldapOnly?: boolean;

View file

@ -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<CustomProfileFieldModel>;
/** Relation to the user */
user: Relation<UserModel>;
}
export default CustomProfileAttributeModel;

View file

@ -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<CustomProfileAttributeModel>;
}
export default CustomProfileFieldModel;

View file

@ -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<CustomProfileAttributeModel> | undefined;
}
export default UserModel;

View file

@ -137,4 +137,6 @@ type RawValue =
| TeamThreadsSync
| UserProfile
| SlashCommand
| CustomProfileAttribute
| CustomProfileField
| Pick<ChannelMembership, 'channel_id' | 'user_id'>