From 87395e1210d7ebf71b448a0f7f75f9539a8b1601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Sun, 12 Oct 2025 21:48:50 +0200 Subject: [PATCH] [MM-65984] DB changes to handle Playbook Run Attributes (#9172) * db changes * temp tests * missing mock models * test helpers improvement * address naming errors * added comments to schema * x2 * minor corrections * Update app/products/playbooks/database/models/playbook_run_attribute_value.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * docs --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/database/manager/__mocks__/index.ts | 4 +- app/database/manager/index.ts | 4 +- app/database/migration/server/index.ts | 29 +- app/database/schema/server/index.ts | 6 +- app/database/schema/server/test.ts | 44 ++- app/products/playbooks/constants/database.ts | 2 + .../playbooks/database/models/index.ts | 2 + .../models/playbook_run_attribute.test.ts | 113 +++++++ .../database/models/playbook_run_attribute.ts | 51 +++ .../playbook_run_attribute_value.test.ts | 175 +++++++++++ .../models/playbook_run_attribute_value.ts | 47 +++ .../database/operators/handlers/index.test.ts | 152 ++++++++- .../database/operators/handlers/index.ts | 68 +++- .../operators/transformers/index.test.ts | 290 +++++++++++++++++- .../database/operators/transformers/index.ts | 72 ++++- .../playbooks/database/schema/index.ts | 2 + .../database/schema/playbook_run_attribute.ts | 31 ++ .../schema/playbook_run_attribute_value.ts | 25 ++ app/products/playbooks/types/api.d.ts | 20 ++ .../database/models/playbook_run_attribute.ts | 39 +++ .../models/playbook_run_attribute_value.ts | 30 ++ .../types/database/transformers/index.d.ts | 8 + docs/database/server/server.md | 22 +- test/test_helper.ts | 78 ++++- 24 files changed, 1297 insertions(+), 17 deletions(-) create mode 100644 app/products/playbooks/database/models/playbook_run_attribute.test.ts create mode 100644 app/products/playbooks/database/models/playbook_run_attribute.ts create mode 100644 app/products/playbooks/database/models/playbook_run_attribute_value.test.ts create mode 100644 app/products/playbooks/database/models/playbook_run_attribute_value.ts create mode 100644 app/products/playbooks/database/schema/playbook_run_attribute.ts create mode 100644 app/products/playbooks/database/schema/playbook_run_attribute_value.ts create mode 100644 app/products/playbooks/types/database/models/playbook_run_attribute.ts create mode 100644 app/products/playbooks/types/database/models/playbook_run_attribute_value.ts diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index a5a19339c..7a2a29bcb 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -22,7 +22,7 @@ import AppDataOperator from '@database/operator/app_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator'; import {schema as appSchema} from '@database/schema/app'; import {serverSchema} from '@database/schema/server'; -import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel} from '@playbooks/database/models'; +import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel} from '@playbooks/database/models'; import {deleteIOSDatabase} from '@utils/mattermost_managed'; import {urlSafeBase64Encode} from '@utils/security'; import {removeProtocol} from '@utils/url'; @@ -54,7 +54,7 @@ class DatabaseManagerSingleton { PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, - PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, + PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel, ]; this.databaseDirectory = ''; } diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index e91de142c..91578c63e 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -23,7 +23,7 @@ import ServerDataOperator from '@database/operator/server_data_operator'; import {schema as appSchema} from '@database/schema/app'; import {serverSchema} from '@database/schema/server'; import {beforeUpgrade} from '@helpers/database/upgrade'; -import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel} from '@playbooks/database/models'; +import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel} from '@playbooks/database/models'; import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers'; import {logDebug, logError} from '@utils/log'; import {deleteIOSDatabase, getIOSAppGroupDetails, renameIOSDatabase} from '@utils/mattermost_managed'; @@ -50,7 +50,7 @@ class DatabaseManagerSingleton { PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, - PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, + PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel, ]; this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${documentDirectory}/databases/`; diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 254ee2f9d..6b8870da2 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -22,9 +22,36 @@ const { SCHEDULED_POST, } = MM_TABLES.SERVER; -const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; +const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; export default schemaMigrations({migrations: [ + { + toVersion: 14, + steps: [ + createTable({ + name: PLAYBOOK_RUN_ATTRIBUTE, + 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}, + ], + }), + createTable({ + name: PLAYBOOK_RUN_ATTRIBUTE_VALUE, + columns: [ + {name: 'attribute_id', type: 'string', isIndexed: true}, + {name: 'run_id', type: 'string', isIndexed: true}, + {name: 'value', type: 'string'}, + ], + }), + ], + }, { toVersion: 13, steps: [ diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 12c5b1aee..1c22cd5e6 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -3,7 +3,7 @@ import {type AppSchema, appSchema} from '@nozbe/watermelondb'; -import {PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema} from '@playbooks/database/schema'; +import {PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema, PlaybookRunAttributeSchema, PlaybookRunAttributeValueSchema} from '@playbooks/database/schema'; import { CategorySchema, @@ -45,7 +45,7 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 13, + version: 14, tables: [ CategorySchema, CategoryChannelSchema, @@ -69,6 +69,8 @@ export const serverSchema: AppSchema = appSchema({ PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema, + PlaybookRunAttributeSchema, + PlaybookRunAttributeValueSchema, PostInThreadSchema, PostSchema, PostsInChannelSchema, diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index dfa96d83d..d71a0b6ce 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -47,12 +47,12 @@ const { USER, } = MM_TABLES.SERVER; -const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; +const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 13, + version: 14, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -553,6 +553,46 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'update_at', type: 'number'}, ], }, + [PLAYBOOK_RUN_ATTRIBUTE]: { + name: PLAYBOOK_RUN_ATTRIBUTE, + 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}, + ], + }, + [PLAYBOOK_RUN_ATTRIBUTE_VALUE]: { + name: PLAYBOOK_RUN_ATTRIBUTE_VALUE, + unsafeSql: undefined, + columns: { + attribute_id: {name: 'attribute_id', type: 'string', isIndexed: true}, + run_id: {name: 'run_id', type: 'string', isIndexed: true}, + value: {name: 'value', type: 'string'}, + }, + columnArray: [ + {name: 'attribute_id', type: 'string', isIndexed: true}, + {name: 'run_id', type: 'string', isIndexed: true}, + {name: 'value', type: 'string'}, + ], + }, [POSTS_IN_THREAD]: { name: POSTS_IN_THREAD, unsafeSql: undefined, diff --git a/app/products/playbooks/constants/database.ts b/app/products/playbooks/constants/database.ts index a08134cb2..dc94c83f1 100644 --- a/app/products/playbooks/constants/database.ts +++ b/app/products/playbooks/constants/database.ts @@ -5,4 +5,6 @@ export const PLAYBOOK_TABLES = { PLAYBOOK_RUN: 'PlaybookRun', PLAYBOOK_CHECKLIST: 'PlaybookChecklist', PLAYBOOK_CHECKLIST_ITEM: 'PlaybookChecklistItem', + PLAYBOOK_RUN_ATTRIBUTE: 'PlaybookRunAttribute', + PLAYBOOK_RUN_ATTRIBUTE_VALUE: 'PlaybookRunAttributeValue', }; diff --git a/app/products/playbooks/database/models/index.ts b/app/products/playbooks/database/models/index.ts index e4c0618e2..358bfa810 100644 --- a/app/products/playbooks/database/models/index.ts +++ b/app/products/playbooks/database/models/index.ts @@ -4,3 +4,5 @@ export {default as PlaybookRunModel} from './playbook_run'; export {default as PlaybookChecklistModel} from './playbook_checklist'; export {default as PlaybookChecklistItemModel} from './playbook_checklist_item'; +export {default as PlaybookRunAttributeModel} from './playbook_run_attribute'; +export {default as PlaybookRunAttributeValueModel} from './playbook_run_attribute_value'; diff --git a/app/products/playbooks/database/models/playbook_run_attribute.test.ts b/app/products/playbooks/database/models/playbook_run_attribute.test.ts new file mode 100644 index 000000000..3a446ff61 --- /dev/null +++ b/app/products/playbooks/database/models/playbook_run_attribute.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; +import TestHelper from '@test/test_helper'; + +import PlaybookRunAttributeModel from './playbook_run_attribute'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +const {PLAYBOOK_RUN_ATTRIBUTE} = PLAYBOOK_TABLES; + +const SERVER_URL = `playbookRunAttributeModel.test.${Date.now()}.com`; + +const applyMockData = (attribute: PlaybookRunAttributeModel, mockData: PlaybookRunAttribute, includeAttrs = true) => { + attribute._raw.id = mockData.id; + attribute.groupId = mockData.group_id; + attribute.name = mockData.name; + attribute.type = mockData.type; + attribute.targetId = mockData.target_id; + attribute.targetType = mockData.target_type; + attribute.createAt = mockData.create_at; + attribute.updateAt = mockData.update_at; + attribute.deleteAt = mockData.delete_at; + if (includeAttrs) { + attribute.attrs = mockData.attrs || '{"placeholder": "Enter value"}'; + } +}; + +describe('PlaybookRunAttributeModel', () => { + let operator: ServerDataOperator; + let playbook_run_attribute: PlaybookRunAttributeModel; + + beforeEach(async () => { + await DatabaseManager.init([SERVER_URL]); + operator = DatabaseManager.serverDatabases[SERVER_URL]!.operator; + const {database} = operator; + + await database.write(async () => { + playbook_run_attribute = await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunAttributeModel) => { + applyMockData(attribute, TestHelper.createPlaybookRunAttribute('test', 0)); + }); + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(SERVER_URL); + }); + + it('=> should match model', () => { + expect(playbook_run_attribute).toBeDefined(); + expect(playbook_run_attribute.id).toBe('test-attribute_0'); + expect(playbook_run_attribute.groupId).toBe('group_1'); + expect(playbook_run_attribute.name).toBe('Attribute 1'); + expect(playbook_run_attribute.type).toBe('text'); + expect(playbook_run_attribute.targetId).toBe('test'); + expect(playbook_run_attribute.targetType).toBe('playbook_run'); + expect(playbook_run_attribute.createAt).toBeGreaterThan(0); + expect(playbook_run_attribute.updateAt).toBeGreaterThan(0); + expect(playbook_run_attribute.deleteAt).toBe(0); + expect(playbook_run_attribute.attrs).toBe('{"placeholder": "Enter value"}'); + }); + + it('=> should have the correct table name', () => { + expect(PlaybookRunAttributeModel.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); + }); + + it('=> should handle optional attrs field', async () => { + let attributeWithoutAttrs: PlaybookRunAttributeModel; + const {database} = operator; + + await database.write(async () => { + attributeWithoutAttrs = await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunAttributeModel) => { + applyMockData(attribute, TestHelper.createPlaybookRunAttribute('test', 1), false); + }); + }); + + expect(attributeWithoutAttrs!).toBeDefined(); + expect(attributeWithoutAttrs!.id).toBe('test-attribute_1'); + expect(attributeWithoutAttrs!.attrs).toBeNull(); + }); + + it('=> should allow updating fields', async () => { + const {database} = operator; + + await database.write(async () => { + await playbook_run_attribute.update((attribute: PlaybookRunAttributeModel) => { + attribute.name = 'Updated Attribute Name'; + attribute.type = 'number'; + attribute.updateAt = 1620000005000; + attribute.attrs = '{"min": 0, "max": 100}'; + }); + }); + + expect(playbook_run_attribute.name).toBe('Updated Attribute Name'); + expect(playbook_run_attribute.type).toBe('number'); + expect(playbook_run_attribute.updateAt).toBe(1620000005000); + expect(playbook_run_attribute.attrs).toBe('{"min": 0, "max": 100}'); + }); + + it('=> should handle deleteAt field correctly', async () => { + const {database} = operator; + + await database.write(async () => { + await playbook_run_attribute.update((attribute: PlaybookRunAttributeModel) => { + attribute.deleteAt = 1620000010000; + }); + }); + + expect(playbook_run_attribute.deleteAt).toBe(1620000010000); + }); +}); diff --git a/app/products/playbooks/database/models/playbook_run_attribute.ts b/app/products/playbooks/database/models/playbook_run_attribute.ts new file mode 100644 index 000000000..af131f1cf --- /dev/null +++ b/app/products/playbooks/database/models/playbook_run_attribute.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {field} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; + +import type PlaybookRunAttributeModelInterface from '@playbooks/types/database/models/playbook_run_attribute'; + +const {PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; + +/** + * The model represents a playbook run attribute definition in the Mattermost app. + */ +export default class PlaybookRunAttributeModel extends Model implements PlaybookRunAttributeModelInterface { + /** table (name) : PlaybookRunAttribute */ + static table = PLAYBOOK_RUN_ATTRIBUTE; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + [PLAYBOOK_RUN_ATTRIBUTE_VALUE]: {type: 'has_many', foreignKey: 'attribute_id'}, + }; + + /** groupId : The group ID of the attribute */ + @field('group_id') groupId!: string; + + /** name : The name of the attribute */ + @field('name') name!: string; + + /** type : The type of the attribute */ + @field('type') type!: string; + + /** targetId : The target ID of the attribute */ + @field('target_id') targetId!: string; + + /** targetType : The target type of the attribute */ + @field('target_type') targetType!: string; + + /** createAt : The timestamp of when the attribute was created */ + @field('create_at') createAt!: number; + + /** updateAt : The timestamp of when the attribute was last updated */ + @field('update_at') updateAt!: number; + + /** deleteAt : The timestamp of when the attribute was deleted */ + @field('delete_at') deleteAt!: number; + + /** attrs : Additional attributes for the attribute (JSON string) */ + @field('attrs') attrs?: string; +} diff --git a/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts b/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts new file mode 100644 index 000000000..a2912da7f --- /dev/null +++ b/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; +import TestHelper from '@test/test_helper'; + +import PlaybookRunModel from './playbook_run'; +import PlaybookRunAttributeModel from './playbook_run_attribute'; +import PlaybookRunAttributeValueModel from './playbook_run_attribute_value'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +const {PLAYBOOK_RUN_ATTRIBUTE_VALUE, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN} = PLAYBOOK_TABLES; + +const SERVER_URL = `playbookRunAttributeValueModel.test.${Date.now()}.com`; + +const applyRunData = (run: PlaybookRunModel, mockData: PlaybookRun) => { + run._raw.id = mockData.id; + run.playbookId = mockData.playbook_id; + run.postId = mockData.post_id ?? null; + run.ownerUserId = mockData.owner_user_id; + run.teamId = mockData.team_id; + run.channelId = mockData.channel_id; + run.createAt = mockData.create_at; + run.endAt = mockData.end_at; + run.name = mockData.name; + run.description = mockData.description; + run.isActive = mockData.is_active; + run.activeStage = mockData.active_stage; + run.activeStageTitle = mockData.active_stage_title; + run.participantIds = mockData.participant_ids; + run.summary = mockData.summary; + run.currentStatus = mockData.current_status; + run.lastStatusUpdateAt = mockData.last_status_update_at; + run.retrospectiveEnabled = mockData.retrospective_enabled; + run.retrospective = mockData.retrospective; + run.retrospectivePublishedAt = mockData.retrospective_published_at; + run.updateAt = mockData.update_at; +}; + +const applyAttributeData = (attribute: PlaybookRunAttributeModel, mockData: PlaybookRunAttribute) => { + attribute._raw.id = mockData.id; + attribute.groupId = mockData.group_id; + attribute.name = mockData.name; + attribute.type = mockData.type; + attribute.targetId = mockData.target_id; + attribute.targetType = mockData.target_type; + attribute.createAt = mockData.create_at; + attribute.updateAt = mockData.update_at; + attribute.deleteAt = mockData.delete_at; + attribute.attrs = mockData.attrs; +}; + +const applyAttributeValueData = (attributeValue: PlaybookRunAttributeValueModel, mockData: PlaybookRunAttributeValue) => { + attributeValue._raw.id = mockData.id; + attributeValue.attributeId = mockData.attribute_id; + attributeValue.runId = mockData.run_id; + attributeValue.value = mockData.value; +}; + +describe('PlaybookRunAttributeValueModel', () => { + let operator: ServerDataOperator; + let playbook_run_attribute_value: PlaybookRunAttributeValueModel; + + beforeEach(async () => { + await DatabaseManager.init([SERVER_URL]); + operator = DatabaseManager.serverDatabases[SERVER_URL]!.operator; + const {database} = operator; + + const mockRun = TestHelper.createPlaybookRuns(1, 0, 0)[0]; + const mockAttribute = TestHelper.createPlaybookRunAttribute('test', 0); + const mockAttributeValue = TestHelper.createPlaybookRunAttributeValue(mockAttribute.id, mockRun.id, 0); + + await database.write(async () => { + await database.get(PLAYBOOK_RUN).create((run) => applyRunData(run, mockRun)); + await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attr) => applyAttributeData(attr, mockAttribute)); + playbook_run_attribute_value = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attrVal) => applyAttributeValueData(attrVal, mockAttributeValue)); + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(SERVER_URL); + }); + + it('=> should match model', () => { + expect(playbook_run_attribute_value).toBeDefined(); + expect(playbook_run_attribute_value.id).toBe('playbook_run_0-test-attribute_0-value_0'); + expect(playbook_run_attribute_value.attributeId).toBe('test-attribute_0'); + expect(playbook_run_attribute_value.runId).toBe('playbook_run_0'); + expect(playbook_run_attribute_value.value).toBe('Value 1'); + }); + + it('=> should have the correct table name', () => { + expect(PlaybookRunAttributeValueModel.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); + }); + + it('=> should have correct associations defined', () => { + expect(PlaybookRunAttributeValueModel.associations).toBeDefined(); + expect(PlaybookRunAttributeValueModel.associations[PLAYBOOK_RUN_ATTRIBUTE]).toEqual({ + type: 'belongs_to', + key: 'attribute_id', + }); + expect(PlaybookRunAttributeValueModel.associations[PLAYBOOK_RUN]).toEqual({ + type: 'belongs_to', + key: 'run_id', + }); + }); + + it('=> should have attribute relation', async () => { + const attributeRelation = playbook_run_attribute_value.attribute; + expect(attributeRelation).toBeDefined(); + + const relatedAttribute = await attributeRelation.fetch(); + expect(relatedAttribute).toBeDefined(); + expect(relatedAttribute!.id).toBe('test-attribute_0'); + expect(relatedAttribute!.name).toBe('Attribute 1'); + }); + + it('=> should have run relation', async () => { + const runRelation = playbook_run_attribute_value.run; + expect(runRelation).toBeDefined(); + + const relatedRun = await runRelation.fetch(); + expect(relatedRun).toBeDefined(); + expect(relatedRun!.id).toBe('playbook_run_0'); + expect(relatedRun!.name).toBe('Playbook Run 1'); + }); + + it('=> should allow updating value', async () => { + const {database} = operator; + + await database.write(async () => { + await playbook_run_attribute_value.update((attributeValue: PlaybookRunAttributeValueModel) => { + attributeValue.value = 'Updated Test Value'; + }); + }); + + expect(playbook_run_attribute_value.value).toBe('Updated Test Value'); + }); + + it('=> should handle empty value', async () => { + let attributeValueWithEmptyValue: PlaybookRunAttributeValueModel; + const {database} = operator; + + await database.write(async () => { + const mockData = TestHelper.createPlaybookRunAttributeValue('test-attribute_0', 'playbook_run_0', 1); + attributeValueWithEmptyValue = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunAttributeValueModel) => { + applyAttributeValueData(attributeValue, mockData); + attributeValue.value = ''; + }); + }); + + expect(attributeValueWithEmptyValue!).toBeDefined(); + expect(attributeValueWithEmptyValue!.value).toBe(''); + }); + + it('=> should handle long text values', async () => { + const longValue = 'This is a very long text value that might be used to store detailed information about the playbook run attribute. It could contain multiple sentences and various types of content.'; + + let attributeValueWithLongText: PlaybookRunAttributeValueModel; + const {database} = operator; + + await database.write(async () => { + const mockData = TestHelper.createPlaybookRunAttributeValue('test-attribute_0', 'playbook_run_0', 2); + attributeValueWithLongText = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunAttributeValueModel) => { + applyAttributeValueData(attributeValue, mockData); + attributeValue.value = longValue; + }); + }); + + expect(attributeValueWithLongText!).toBeDefined(); + expect(attributeValueWithLongText!.value).toBe(longValue); + }); +}); diff --git a/app/products/playbooks/database/models/playbook_run_attribute_value.ts b/app/products/playbooks/database/models/playbook_run_attribute_value.ts new file mode 100644 index 000000000..f10a87fbb --- /dev/null +++ b/app/products/playbooks/database/models/playbook_run_attribute_value.ts @@ -0,0 +1,47 @@ +// 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 {PLAYBOOK_TABLES} from '@playbooks/constants/database'; + +import type PlaybookRunModel from './playbook_run'; +import type PlaybookRunAttributeModel from './playbook_run_attribute'; +import type {Relation} from '@nozbe/watermelondb'; +import type PlaybookRunAttributeValueModelInterface from '@playbooks/types/database/models/playbook_run_attribute_value'; + +const {PLAYBOOK_RUN, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; + +/** + * The PlaybookRunAttributeValue model represents a playbook run attribute value in the Mattermost app. + */ +export default class PlaybookRunAttributeValueModel extends Model implements PlaybookRunAttributeValueModelInterface { + /** table (name) : PlaybookRunAttributeValue */ + static table = PLAYBOOK_RUN_ATTRIBUTE_VALUE; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A PLAYBOOK_RUN_ATTRIBUTE is associated to a PLAYBOOK_RUN_ATTRIBUTE_VALUE (relationship is 1:1) */ + [PLAYBOOK_RUN_ATTRIBUTE]: {type: 'belongs_to', key: 'attribute_id'}, + + /** A PLAYBOOK_RUN is associated to a PLAYBOOK_RUN_ATTRIBUTE (relationship is 1:1) */ + [PLAYBOOK_RUN]: {type: 'belongs_to', key: 'run_id'}, + }; + + /** attributeId : The ID of the attribute this attribute value belongs to */ + @field('attribute_id') attributeId!: string; + + /** runId : The ID of the playbook run this attribute belongs to */ + @field('run_id') runId!: string; + + /** value : The value of the attribute */ + @field('value') value!: string; + + /** attribute : The attribute this attribute value belongs to */ + @immutableRelation(PLAYBOOK_RUN_ATTRIBUTE, 'attribute_id') attribute!: Relation; + + /** run : The playbook run this attribute belongs to */ + @immutableRelation(PLAYBOOK_RUN, 'run_id') run!: Relation; +} diff --git a/app/products/playbooks/database/operators/handlers/index.test.ts b/app/products/playbooks/database/operators/handlers/index.test.ts index 59cf1d960..30fd42d1a 100644 --- a/app/products/playbooks/database/operators/handlers/index.test.ts +++ b/app/products/playbooks/database/operators/handlers/index.test.ts @@ -8,7 +8,7 @@ import TestHelper from '@test/test_helper'; import type ServerDataOperator from '@database/operator/server_data_operator'; -const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; +const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; describe('PlaybookHandler', () => { let operator: ServerDataOperator; @@ -553,4 +553,154 @@ describe('PlaybookHandler', () => { expect(checklistItemRecords.length).toBe(0); }); }); + + describe('handlePlaybookRunAttribute', () => { + it('should return an empty array if attributes is undefined or empty', async () => { + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + + let result = await operator.handlePlaybookRunAttribute({ + attributes: undefined, + prepareRecordsOnly: true, + }); + + expect(result).toEqual([]); + expect(spyOnPrepareRecords).not.toHaveBeenCalled(); + + result = await operator.handlePlaybookRunAttribute({ + attributes: [], + prepareRecordsOnly: true, + }); + + expect(result).toEqual([]); + expect(spyOnPrepareRecords).not.toHaveBeenCalled(); + }); + + it('should process attributes correctly', async () => { + const mockAttributes = [ + TestHelper.createPlaybookRunAttribute('attribute_1', 0), + TestHelper.createPlaybookRunAttribute('attribute_2', 1), + ].map((attribute, index) => ({ + ...attribute, + run_id: 'playbook_run_1', + order: index, + })); + + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); + + const result = await operator.handlePlaybookRunAttribute({ + attributes: mockAttributes, + prepareRecordsOnly: false, + }); + + expect(result).toBeDefined(); + expect(result.length).toBe(mockAttributes.length); + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); + expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); + + const {database} = operator; + + const attributeRecords = await database.get(PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + expect(attributeRecords.length).toBe(mockAttributes.length); + }); + + it('should only prepare records when prepareRecordsOnly is true', async () => { + const mockAttributes = [ + TestHelper.createPlaybookRunAttribute('attribute_3', 2), + ].map((attribute, index) => ({ + ...attribute, + run_id: 'playbook_run_1', + order: index, + })); + + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); + + const result = await operator.handlePlaybookRunAttribute({ + attributes: mockAttributes, + prepareRecordsOnly: true, + }); + + expect(result).toBeDefined(); + expect(result.length).toBe(mockAttributes.length); + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); + expect(spyOnBatchOperation).not.toHaveBeenCalled(); + + const {database} = operator; + + const attributeRecords = await database.get(PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + expect(attributeRecords.length).toBe(0); + }); + }); + + describe('handlePlaybookRunAttributeValue', () => { + it('should return an empty array if attributeValues is undefined or empty', async () => { + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + + let result = await operator.handlePlaybookRunAttributeValue({ + attributeValues: undefined, + prepareRecordsOnly: true, + }); + + expect(result).toEqual([]); + expect(spyOnPrepareRecords).not.toHaveBeenCalled(); + + result = await operator.handlePlaybookRunAttributeValue({ + attributeValues: [], + prepareRecordsOnly: true, + }); + + expect(result).toEqual([]); + expect(spyOnPrepareRecords).not.toHaveBeenCalled(); + }); + + it('should process attribute values correctly', async () => { + const mockAttributeValues = [ + TestHelper.createPlaybookRunAttributeValue('attribute_1', 'playbook_run_1', 0), + TestHelper.createPlaybookRunAttributeValue('attribute_2', 'playbook_run_2', 1), + ]; + + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); + + const result = await operator.handlePlaybookRunAttributeValue({ + attributeValues: mockAttributeValues, + prepareRecordsOnly: false, + }); + + expect(result).toBeDefined(); + expect(result.length).toBe(mockAttributeValues.length); + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); + expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); + + const {database} = operator; + + const attributeValueRecords = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(attributeValueRecords.length).toBe(mockAttributeValues.length); + }); + + it('should only prepare records when prepareRecordsOnly is true', async () => { + const mockAttributeValues = [ + TestHelper.createPlaybookRunAttributeValue('attribute_3', 'playbook_run_3', 2), + ]; + + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); + + const result = await operator.handlePlaybookRunAttributeValue({ + attributeValues: mockAttributeValues, + prepareRecordsOnly: true, + }); + + expect(result).toBeDefined(); + expect(result.length).toBe(mockAttributeValues.length); + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); + expect(spyOnBatchOperation).not.toHaveBeenCalled(); + + const {database} = operator; + + const attributeValueRecords = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(attributeValueRecords.length).toBe(0); + }); + }); }); diff --git a/app/products/playbooks/database/operators/handlers/index.ts b/app/products/playbooks/database/operators/handlers/index.ts index c6c73558b..bde428c5c 100644 --- a/app/products/playbooks/database/operators/handlers/index.ts +++ b/app/products/playbooks/database/operators/handlers/index.ts @@ -8,7 +8,7 @@ import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {logWarning} from '@utils/log'; import {shouldHandlePlaybookChecklistItemRecord, shouldHandlePlaybookChecklistRecord, shouldUpdatePlaybookRunRecord} from '../comparators'; -import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord} from '../transformers'; +import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunAttributeRecord, transformPlaybookRunAttributeValueRecord} from '../transformers'; import type ServerDataOperatorBase from '@database/operator/server_data_operator/handlers'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; @@ -34,12 +34,24 @@ type HandlePlaybookChecklistItemArgs = { items?: PartialChecklistItem[]; } -const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; +type HandlePlaybookRunAttributeArgs = { + prepareRecordsOnly: boolean; + attributes?: PartialPlaybookRunAttribute[]; +} + +type HandlePlaybookRunAttributeValueArgs = { + prepareRecordsOnly: boolean; + attributeValues?: PartialPlaybookRunAttributeValue[]; +} + +const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; export interface PlaybookHandlerMix { handlePlaybookRun: (args: HandlePlaybookRunArgs) => Promise; handlePlaybookChecklist: (args: HandlePlaybookChecklistArgs) => Promise; handlePlaybookChecklistItem: (args: HandlePlaybookChecklistItemArgs) => Promise; + handlePlaybookRunAttribute: (args: HandlePlaybookRunAttributeArgs) => Promise; + handlePlaybookRunAttributeValue: (args: HandlePlaybookRunAttributeValueArgs) => Promise; } const PlaybookHandler = >(superclass: TBase) => class extends superclass { @@ -274,6 +286,58 @@ const PlaybookHandler = >(supe return records; }; + + /** + * Handles the playbook run attribute records. + * @param {HandlePlaybookRunAttributeArgs} args - The arguments for handling playbook run attribute records. + * @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them. + * @param {PlaybookRunAttribute[]} [args.attributes] - The playbook run attribute records to handle. + * @returns {Promise} - A promise that resolves to an array of handled playbook run attribute records. + */ + handlePlaybookRunAttribute = async ({attributes, prepareRecordsOnly = true}: HandlePlaybookRunAttributeArgs): Promise => { + if (!attributes?.length) { + logWarning( + 'An empty or undefined "attributes" array has been passed to the handlePlaybookRunAttribute method', + ); + return []; + } + + const createOrUpdateRawValues = getUniqueRawsBy({raws: attributes, key: 'id'}); + + return this.handleRecords({ + fieldName: 'id', + transformer: transformPlaybookRunAttributeRecord, + createOrUpdateRawValues, + tableName: PLAYBOOK_RUN_ATTRIBUTE, + prepareRecordsOnly, + }, 'handlePlaybookRunAttribute'); + }; + + /** + * Handles the playbook run attribute value records. + * @param {HandlePlaybookRunAttributeValueArgs} args - The arguments for handling playbook run attribute value records. + * @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them. + * @param {PlaybookRunAttributeValue[]} [args.attributeValues] - The playbook run attribute value records to handle. + * @returns {Promise} - A promise that resolves to an array of handled playbook run attribute value records. + */ + handlePlaybookRunAttributeValue = async ({attributeValues, prepareRecordsOnly = true}: HandlePlaybookRunAttributeValueArgs): Promise => { + if (!attributeValues?.length) { + logWarning( + 'An empty or undefined "attributeValues" array has been passed to the handlePlaybookRunAttributeValue method', + ); + return []; + } + + const createOrUpdateRawValues = getUniqueRawsBy({raws: attributeValues, key: 'id'}); + + return this.handleRecords({ + fieldName: 'id', + transformer: transformPlaybookRunAttributeValueRecord, + createOrUpdateRawValues, + tableName: PLAYBOOK_RUN_ATTRIBUTE_VALUE, + prepareRecordsOnly, + }, 'handlePlaybookRunAttributeValue'); + }; }; export default PlaybookHandler; diff --git a/app/products/playbooks/database/operators/transformers/index.test.ts b/app/products/playbooks/database/operators/transformers/index.test.ts index 1228562b2..36bf8ab6b 100644 --- a/app/products/playbooks/database/operators/transformers/index.test.ts +++ b/app/products/playbooks/database/operators/transformers/index.test.ts @@ -8,12 +8,14 @@ import {createTestConnection} from '@database/operator/utils/create_test_connect import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {PlaybookRunModel} from '@playbooks/database/models'; -import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord} from '.'; +import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunAttributeRecord, transformPlaybookRunAttributeValueRecord} from '.'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; +import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; -const {PLAYBOOK_RUN} = PLAYBOOK_TABLES; +const {PLAYBOOK_RUN, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => { it('=> transformPlaybookRunRecord: should return a record of type PlaybookRun for CREATE action', async () => { @@ -774,3 +776,287 @@ describe('*** PLAYBOOK_CHECKLIST_ITEM Prepare Records Test ***', () => { expect(preparedRecordDefault!.conditionReason).toBe(''); }); }); + +describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { + it('=> transformPlaybookRunAttributeRecord: should return a record of type PlaybookRunAttribute for CREATE action', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecord = await transformPlaybookRunAttributeRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'attribute_1', + group_id: 'group_1', + name: 'Test Attribute', + type: 'text', + target_id: 'target_1', + target_type: 'playbook_run', + create_at: 1620000000000, + update_at: 1620000001000, + delete_at: 0, + attrs: '{"placeholder": "Enter value"}', + }, + }, + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); + }); + + it('=> transformPlaybookRunAttributeRecord: should return a record of type PlaybookRunAttribute for UPDATE action', async () => { + expect.assertions(4); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + let existingRecord: PlaybookRunAttributeModel | undefined; + await database!.write(async () => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { + record._raw.id = 'attribute_2'; + record.groupId = 'group_2'; + record.name = 'Existing Attribute'; + record.type = 'text'; + record.targetId = 'target_2'; + record.targetType = 'playbook_run'; + record.createAt = 1620000000000; + record.updateAt = 1620000001000; + record.deleteAt = 0; + record.attrs = '{"placeholder": "Original placeholder"}'; + }); + }); + + const preparedRecord = await transformPlaybookRunAttributeRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: existingRecord, + raw: { + id: 'attribute_2', + group_id: 'group_2', + name: 'Updated Attribute', + type: 'text', + target_id: 'target_2', + target_type: 'playbook_run', + create_at: 1620000000000, + update_at: 1620000002000, + delete_at: 0, + attrs: '{"placeholder": "Updated placeholder"}', + }, + }, + }); + + await database?.write(async () => { + await database?.batch(preparedRecord); + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.name).toBe('Updated Attribute'); + expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); + }); + + it('=> transformPlaybookRunAttributeRecord: should throw an error for non-create action without an existing record', async () => { + expect.assertions(2); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + await expect( + transformPlaybookRunAttributeRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'attribute_3', + group_id: 'group_3', + name: 'Invalid Attribute', + type: 'text', + target_id: 'target_3', + target_type: 'playbook_run', + create_at: 1620000000000, + update_at: 1620000001000, + delete_at: 0, + }, + }, + }), + ).rejects.toThrow('Record not found for non create action'); + }); + + it('=> transformPlaybookRunAttributeRecord: should keep most of the data if the partial attribute is empty', async () => { + const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + let existingRecord: PlaybookRunAttributeModel | undefined; + await database!.write(async () => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { + record._raw.id = 'attribute_2'; + record.groupId = 'group_2'; + record.name = 'Existing Attribute'; + record.type = 'text'; + record.targetId = 'target_2'; + record.targetType = 'playbook_run'; + record.createAt = 1620000000000; + record.updateAt = 1620000001000; + record.deleteAt = 0; + record.attrs = '{"placeholder": "Original"}'; + }); + }); + + const preparedRecord = await transformPlaybookRunAttributeRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: existingRecord, + raw: { + id: 'attribute_2', + update_at: 1620000004000, + }, + }, + }); + + await database?.write(async () => { + await database?.batch(preparedRecord); + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord.groupId).toBe('group_2'); + expect(preparedRecord.name).toBe('Existing Attribute'); + expect(preparedRecord.type).toBe('text'); + expect(preparedRecord.targetId).toBe('target_2'); + expect(preparedRecord.targetType).toBe('playbook_run'); + expect(preparedRecord.createAt).toBe(1620000000000); + expect(preparedRecord.deleteAt).toBe(0); + expect(preparedRecord.attrs).toBe('{"placeholder": "Original"}'); + expect(preparedRecord.updateAt).toBe(1620000004000); + }); +}); + +describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { + it('=> transformPlaybookRunAttributeValueRecord: should return a record of type PlaybookRunAttributeValue for CREATE action', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'attribute_value_1', + attribute_id: 'attribute_1', + run_id: 'playbook_run_1', + value: 'Test Value', + }, + }, + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); + }); + + it('=> transformPlaybookRunAttributeValueRecord: should return a record of type PlaybookRunAttributeValue for UPDATE action', async () => { + expect.assertions(4); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + let existingRecord: PlaybookRunAttributeValueModel | undefined; + await database!.write(async () => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + record._raw.id = 'attribute_value_2'; + record.attributeId = 'attribute_2'; + record.runId = 'playbook_run_2'; + record.value = 'Existing Value'; + }); + }); + + const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: existingRecord, + raw: { + id: 'attribute_value_2', + attribute_id: 'attribute_2', + run_id: 'playbook_run_2', + value: 'Updated Value', + }, + }, + }); + + await database?.write(async () => { + await database?.batch(preparedRecord); + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord!.value).toBe('Updated Value'); + expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); + }); + + it('=> transformPlaybookRunAttributeValueRecord: should throw an error for non-create action without an existing record', async () => { + expect.assertions(2); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + await expect( + transformPlaybookRunAttributeValueRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'attribute_value_3', + attribute_id: 'attribute_3', + run_id: 'playbook_run_3', + value: 'Invalid Value', + }, + }, + }), + ).rejects.toThrow('Record not found for non create action'); + }); + + it('=> transformPlaybookRunAttributeValueRecord: should keep most of the data if the partial attribute value is empty', async () => { + const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + let existingRecord: PlaybookRunAttributeValueModel | undefined; + await database!.write(async () => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + record._raw.id = 'attribute_value_2'; + record.attributeId = 'attribute_2'; + record.runId = 'playbook_run_2'; + record.value = 'Existing Value'; + }); + }); + + const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: existingRecord, + raw: { + id: 'attribute_value_2', + run_id: 'playbook_run_2', + attribute_id: 'attribute_2', + }, + }, + }); + + await database?.write(async () => { + await database?.batch(preparedRecord); + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord.attributeId).toBe('attribute_2'); + expect(preparedRecord.runId).toBe('playbook_run_2'); + expect(preparedRecord.value).toBe('Existing Value'); + }); +}); diff --git a/app/products/playbooks/database/operators/transformers/index.ts b/app/products/playbooks/database/operators/transformers/index.ts index fa337f8de..81f3a043c 100644 --- a/app/products/playbooks/database/operators/transformers/index.ts +++ b/app/products/playbooks/database/operators/transformers/index.ts @@ -8,9 +8,11 @@ import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; +import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; import type{TransformerArgs} from '@typings/database/database'; -const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES; +const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; /** * transformPlaybookRunRecord: Prepares a record of the SERVER database 'PlaybookRun' table for update or create actions. @@ -140,3 +142,71 @@ export const transformPlaybookChecklistItemRecord = ({action, database, value}: fieldsMapper, }); }; + +/** + * transformPlaybookRunAttributeRecord: Prepares a record of the SERVER database 'PlaybookRunAttribute' table for update or create actions. + * @param {TransformerArgs} transformerArgs + * @param {Database} transformerArgs.database + * @param {RecordPair} transformerArgs.value + * @returns {Promise} + */ +export const transformPlaybookRunAttributeRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw; + const record = value.record; + const isCreateAction = action === OperationType.CREATE; + if (!isCreateAction && !record) { + return Promise.reject(new Error('Record not found for non create action')); + } + + const fieldsMapper = (attribute: PlaybookRunAttributeModel) => { + attribute._raw.id = isCreateAction ? (raw?.id ?? attribute.id) : record!.id; + attribute.groupId = raw.group_id ?? record?.groupId ?? ''; + attribute.name = raw.name ?? record?.name ?? ''; + attribute.type = raw.type ?? record?.type ?? ''; + attribute.targetId = raw.target_id ?? record?.targetId ?? ''; + attribute.targetType = raw.target_type ?? record?.targetType ?? ''; + attribute.createAt = raw.create_at ?? record?.createAt ?? 0; + attribute.updateAt = raw.update_at ?? record?.updateAt ?? 0; + attribute.deleteAt = raw.delete_at ?? record?.deleteAt ?? 0; + attribute.attrs = raw.attrs ?? record?.attrs ?? ''; + }; + + return prepareBaseRecord({ + action, + database, + tableName: PLAYBOOK_RUN_ATTRIBUTE, + value, + fieldsMapper, + }); +}; + +/** + * transformPlaybookRunAttributeValueRecord: Prepares a record of the SERVER database 'PlaybookRunAttributeValue' table for update or create actions. + * @param {TransformerArgs} transformerArgs + * @param {Database} transformerArgs.database + * @param {RecordPair} transformerArgs.value + * @returns {Promise} + */ +export const transformPlaybookRunAttributeValueRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw; + const record = value.record; + const isCreateAction = action === OperationType.CREATE; + if (!isCreateAction && !record) { + return Promise.reject(new Error('Record not found for non create action')); + } + + const fieldsMapper = (attribute: PlaybookRunAttributeValueModel) => { + attribute._raw.id = isCreateAction ? (raw?.id ?? attribute.id) : record!.id; + attribute.attributeId = raw.attribute_id ?? record?.attributeId ?? ''; + attribute.runId = raw.run_id ?? record?.runId ?? ''; + attribute.value = raw.value ?? record?.value ?? ''; + }; + + return prepareBaseRecord({ + action, + database, + tableName: PLAYBOOK_RUN_ATTRIBUTE_VALUE, + value, + fieldsMapper, + }); +}; diff --git a/app/products/playbooks/database/schema/index.ts b/app/products/playbooks/database/schema/index.ts index 545c2c783..1137539b1 100644 --- a/app/products/playbooks/database/schema/index.ts +++ b/app/products/playbooks/database/schema/index.ts @@ -4,3 +4,5 @@ export {default as PlaybookRunSchema} from './playbook_run'; export {default as PlaybookChecklistSchema} from './playbook_checklist'; export {default as PlaybookChecklistItemSchema} from './playbook_checklist_item'; +export {default as PlaybookRunAttributeSchema} from './playbook_run_attribute'; +export {default as PlaybookRunAttributeValueSchema} from './playbook_run_attribute_value'; diff --git a/app/products/playbooks/database/schema/playbook_run_attribute.ts b/app/products/playbooks/database/schema/playbook_run_attribute.ts new file mode 100644 index 000000000..38357dec7 --- /dev/null +++ b/app/products/playbooks/database/schema/playbook_run_attribute.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; + +const {PLAYBOOK_RUN_ATTRIBUTE} = PLAYBOOK_TABLES; + +/** + * The schema for the PLAYBOOK_RUN_ATTRIBUTE table. + * notice that this mimics closely the Custom Profile Attribute table schema. As there is a base element to both on properties. + * After discussing, we decided to have a separate client schema for each feature based on System Attributes. + * although we moved away from field/attribute to an attribute/attribute value which might have some initial confusion but + * will eventually be more consistent. + */ + +export default tableSchema({ + name: PLAYBOOK_RUN_ATTRIBUTE, + 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/products/playbooks/database/schema/playbook_run_attribute_value.ts b/app/products/playbooks/database/schema/playbook_run_attribute_value.ts new file mode 100644 index 000000000..ed6de66dc --- /dev/null +++ b/app/products/playbooks/database/schema/playbook_run_attribute_value.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; + +const {PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; + +/** + * The schema for the PLAYBOOK_RUN_ATTRIBUTE_VALUE table. + * notice that this mimics closely the Custom Profile Attribute table schema. As there is a base element to both on properties. + * After discussing, we decided to have a separate client schema for each feature based on System Attributes. + * although we moved away from field/attribute to an attribute/attribute value which might have some initial confusion but + * will eventually be more consistent. + */ + +export default tableSchema({ + name: PLAYBOOK_RUN_ATTRIBUTE_VALUE, + columns: [ + {name: 'attribute_id', type: 'string', isIndexed: true}, + {name: 'run_id', type: 'string', isIndexed: true}, + {name: 'value', type: 'string'}, + ], +}); diff --git a/app/products/playbooks/types/api.d.ts b/app/products/playbooks/types/api.d.ts index 5e3ba86c3..74dc72700 100644 --- a/app/products/playbooks/types/api.d.ts +++ b/app/products/playbooks/types/api.d.ts @@ -71,6 +71,26 @@ type TimelineEvent = { type PlaybookRunStatusType = typeof PlaybookRunStatus[keyof typeof PlaybookRunStatus]; +type PlaybookRunAttribute = { + id: string; + group_id: string; + name: string; + type: string; + target_id: string; + target_type: string; + create_at: number; + update_at: number; + delete_at: number; + attrs?: string; +} + +type PlaybookRunAttributeValue = { + id: string; + attribute_id: string; + run_id: string; + value: string; +} + type PlaybookRun = { id: string; name: string; diff --git a/app/products/playbooks/types/database/models/playbook_run_attribute.ts b/app/products/playbooks/types/database/models/playbook_run_attribute.ts new file mode 100644 index 000000000..c998a1020 --- /dev/null +++ b/app/products/playbooks/types/database/models/playbook_run_attribute.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type Model from '@nozbe/watermelondb/Model'; + +/** + * The PlaybookRunAttribute interface, which will have all the fields from the PLAYBOOK_RUN_ATTRIBUTE table + */ +interface PlaybookRunAttributeModelInterface extends Model { + + /** groupId : The group ID of the field */ + groupId: string; + + /** name : The name of the field */ + name: string; + + /** type : The type of the field */ + type: string; + + /** targetId : The target ID of the field */ + targetId: string; + + /** targetType : The target type of the field */ + targetType: string; + + /** createAt : The timestamp of when the field was created */ + createAt: number; + + /** updateAt : The timestamp of when the field was last updated */ + updateAt: number; + + /** deleteAt : The timestamp of when the field was deleted */ + deleteAt: number; + + /** attrs : Additional attributes for the field (JSON string) */ + attrs?: string; +} + +export type {PlaybookRunAttributeModelInterface as default}; diff --git a/app/products/playbooks/types/database/models/playbook_run_attribute_value.ts b/app/products/playbooks/types/database/models/playbook_run_attribute_value.ts new file mode 100644 index 000000000..a9267a1ae --- /dev/null +++ b/app/products/playbooks/types/database/models/playbook_run_attribute_value.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type PlaybookRunModel from './playbook_run'; +import type PlaybookRunAttributeModel from './playbook_run_attribute'; +import type {Relation} from '@nozbe/watermelondb'; +import type Model from '@nozbe/watermelondb/Model'; + +/** + * The PlaybookRunAttributeValue interface, which will have all the fields from the PLAYBOOK_RUN_ATTRIBUTE_VALUE table + */ +interface PlaybookRunAttributeValueModelInterface extends Model { + + /** attributeId : The ID of the attribute this attribute value belongs to */ + attributeId: string; + + /** runId : The ID of the playbook run this attribute belongs to */ + runId: string; + + /** value : The value of the attribute */ + value: string; + + /** attribute : The attribute this attribute value belongs to */ + attribute: Relation; + + /** run : The playbook run this attribute belongs to */ + run: Relation; +} + +export type {PlaybookRunAttributeValueModelInterface as default}; diff --git a/app/products/playbooks/types/database/transformers/index.d.ts b/app/products/playbooks/types/database/transformers/index.d.ts index c8f81253a..a349c355a 100644 --- a/app/products/playbooks/types/database/transformers/index.d.ts +++ b/app/products/playbooks/types/database/transformers/index.d.ts @@ -7,8 +7,16 @@ type WithRunId = { type WithChecklistId = { checklist_id: string; } + +type WithAttributeId = { + attribute_id: string; +} + type PartialWithId = Partial & Pick; type PartialPlaybookRun = PartialWithId; type PartialChecklist = PartialWithId & WithRunId; type PartialChecklistItem = PartialWithId & WithChecklistId; + +type PartialPlaybookRunAttribute = PartialWithId; +type PartialPlaybookRunAttributeValue = PartialWithId & WithRunId & WithAttributeId; diff --git a/docs/database/server/server.md b/docs/database/server/server.md index c64279008..75a1367d3 100644 --- a/docs/database/server/server.md +++ b/docs/database/server/server.md @@ -1,4 +1,4 @@ -# Server Database - Schema Version 13 +# Server Database - Schema Version 14 # 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. @@ -230,6 +230,26 @@ completed_at number synced string NULL INDEX # optional field for sync status last_sync_at number NULL # optional field for last sync timestamp +PlaybookRunAttribute +- +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 + +PlaybookRunAttributeValue +- +id PK string # composition ID PlaybookRun.id-PlaybookRunAttribute.id +attribute_id string INDEX FK >- PlaybookRunAttribute.id +run_id string INDEX FK >- PlaybookRun.id +value string + Post - id PK string # server generated diff --git a/test/test_helper.ts b/test/test_helper.ts index db5072a8f..e2dfcd699 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -23,6 +23,8 @@ import type {Model, Query, Relation} from '@nozbe/watermelondb'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; +import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; @@ -1067,7 +1069,7 @@ class TestHelperSingleton { }; }; - createPlaybookItem =(prefix: string, index: number): PlaybookChecklistItem => ({ + createPlaybookItem = (prefix: string, index: number): PlaybookChecklistItem => ({ id: `${prefix}-item_${index}`, title: `Item ${index + 1} of Checklist ${prefix}`, description: 'Item description', @@ -1085,6 +1087,26 @@ class TestHelperSingleton { update_at: 0, }); + createPlaybookRunAttribute = (prefix: string, index: number): PlaybookRunAttribute => ({ + id: `${prefix}-attribute_${index}`, + group_id: 'group_1', + name: `Attribute ${index + 1}`, + type: 'text', + target_id: `${prefix}`, + target_type: 'playbook_run', + create_at: Date.now(), + update_at: Date.now(), + delete_at: 0, + attrs: '', + }); + + createPlaybookRunAttributeValue = (attributeId: string, runId: string, index: number): PlaybookRunAttributeValue => ({ + id: `${runId}-${attributeId}-value_${index}`, + attribute_id: attributeId, + run_id: runId, + value: `Value ${index + 1}`, + }); + createPlaybookChecklist = (prefix: string, itemsCount: number, index: number): PlaybookChecklist => { const items: PlaybookChecklistItem[] = []; const id = `${prefix}-checklist_${index}`; @@ -1263,6 +1285,60 @@ class TestHelperSingleton { }; }; + fakePlaybookRunAttribute = (overwrite: Partial = {}): PlaybookRunAttribute => { + return { + id: this.generateId(), + group_id: this.generateId(), + name: 'Test Attribute', + type: 'text', + target_id: this.generateId(), + target_type: 'playbook_run', + create_at: Date.now(), + update_at: Date.now(), + delete_at: 0, + attrs: '', + ...overwrite, + }; + }; + + fakePlaybookRunAttributeValue = (attributeId: string, runId: string, overwrite: Partial = {}): PlaybookRunAttributeValue => { + return { + id: this.generateId(), + attribute_id: attributeId, + run_id: runId, + value: 'Test Value', + ...overwrite, + }; + }; + + fakePlaybookRunAttributeModel = (overwrite: Partial = {}): PlaybookRunAttributeModel => { + return { + ...this.fakeModel(), + groupId: this.generateId(), + name: 'Test Attribute', + type: 'text', + targetId: this.generateId(), + targetType: 'playbook_run', + createAt: Date.now(), + updateAt: Date.now(), + deleteAt: 0, + attrs: '', + ...overwrite, + }; + }; + + fakePlaybookRunAttributeValueModel = (overwrite: Partial = {}): PlaybookRunAttributeValueModel => { + return { + ...this.fakeModel(), + attributeId: this.generateId(), + runId: this.generateId(), + value: 'Test Value', + attribute: this.fakeRelation(), + run: this.fakeRelation(), + ...overwrite, + }; + }; + fakeWebsocketMessage = (overwrite: Partial = {}): WebSocketMessage => { return { event: 'test',