diff --git a/src/migration/config-migration/config-migration.service.ts b/src/migration/config-migration/config-migration.service.ts index 1d60165..5cb435b 100644 --- a/src/migration/config-migration/config-migration.service.ts +++ b/src/migration/config-migration/config-migration.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Couchdb } from '../../couchdb/couchdb.service'; +import { migrateAddMissingEntityAttributes } from './migrate-add-entity-attributes'; /** * Apply transformations to the Config document in the CouchDB, @@ -21,6 +22,11 @@ export class ConfigMigrationService { migrateFormHeadersIntoFieldGroups, migrateFormFieldConfigView2ViewComponent, migrateMenuItemConfig, + migrateEntityDetailsInputEntityType, + migrateEntityArrayDatatype, + migrateEntitySchemaDefaultValue, + migrateChildrenListConfig, + migrateHistoricalDataComponent, ]; const newConfig = JSON.parse(JSON.stringify(config), (_that, rawValue) => { @@ -40,7 +46,10 @@ export class ConfigMigrationService { */ async migrateToLatestConfigFormats(couchdb: Couchdb) { const config = await this.getConfigDoc(couchdb); - const newConfig = this.applyMigrations(config); + let newConfig = this.applyMigrations(config); + + newConfig = migrateAddMissingEntityAttributes(newConfig); + await this.saveConfigDoc(couchdb, newConfig); return JSON.stringify(config) !== JSON.stringify(newConfig); } @@ -161,3 +170,125 @@ const migrateMenuItemConfig: ConfigMigration = (key, configPart) => { return configPart; }; + +/** + * Config properties specifying an entityType should be named "entityType" rather than "entity" + * to avoid confusion with a specific instance of an entity being passed in components. + * @param key + * @param configPart + */ +const migrateEntityDetailsInputEntityType: ConfigMigration = ( + key, + configPart, +) => { + if (key !== 'config') { + return configPart; + } + + if (configPart['entity']) { + configPart['entityType'] = configPart['entity']; + delete configPart['entity']; + } + + return configPart; +}; + +/** + * Replace custom "entity-array" dataType with dataType="array", innerDatatype="entity" + * @param key + * @param configPart + */ +const migrateEntityArrayDatatype: ConfigMigration = (key, configPart) => { + if (configPart === 'DisplayEntityArray') { + return 'DisplayEntity'; + } + + if (!configPart?.hasOwnProperty('dataType')) { + return configPart; + } + + const config = configPart; + if (config.dataType === 'entity-array') { + config.dataType = 'entity'; + config.isArray = true; + } + + if (config.dataType === 'array') { + config.dataType = config['innerDataType']; + delete config['innerDataType']; + config.isArray = true; + } + + if (config.dataType === 'configurable-enum' && config['innerDataType']) { + config.additional = config['innerDataType']; + delete config['innerDataType']; + } + + return configPart; +}; + +const migrateEntitySchemaDefaultValue: ConfigMigration = ( + key: string, + configPart: any, +): any => { + if (key !== 'defaultValue') { + return configPart; + } + + if (typeof configPart == 'object') { + return configPart; + } + + let placeholderValue: string | undefined = ['$now', '$current_user'].find( + (value) => value === configPart, + ); + + if (placeholderValue) { + return { + mode: 'dynamic', + value: placeholderValue, + }; + } + + return { + mode: 'static', + value: configPart, + }; +}; + +const migrateChildrenListConfig: ConfigMigration = (key, configPart) => { + if ( + typeof configPart !== 'object' || + configPart?.['component'] !== 'ChildrenList' + ) { + return configPart; + } + + configPart['component'] = 'EntityList'; + + configPart['config'] = configPart['config'] ?? {}; + configPart['config']['entityType'] = 'Child'; + configPart['config']['loaderMethod'] = 'ChildrenService'; + + return configPart; +}; + +const migrateHistoricalDataComponent: ConfigMigration = (key, configPart) => { + if ( + typeof configPart !== 'object' || + configPart?.['component'] !== 'HistoricalDataComponent' + ) { + return configPart; + } + + configPart['component'] = 'RelatedEntities'; + + configPart['config'] = configPart['config'] ?? {}; + if (Array.isArray(configPart['config'])) { + configPart['config'] = { columns: configPart['config'] }; + } + configPart['config']['entityType'] = 'HistoricalEntityData'; + configPart['config']['loaderMethod'] = 'HistoricalDataService'; + + return configPart; +}; diff --git a/src/migration/config-migration/migrate-add-entity-attributes.ts b/src/migration/config-migration/migrate-add-entity-attributes.ts new file mode 100644 index 0000000..7d49d1b --- /dev/null +++ b/src/migration/config-migration/migrate-add-entity-attributes.ts @@ -0,0 +1,532 @@ +export function migrateAddMissingEntityAttributes(config) { + for (let entityType of Object.keys(DEFAULT_ENTITIES)) { + // TODO: just blindly save all hard-coded fields into the entity config? or scan which ones are actually used?! + if (!JSON.stringify(config).includes(`"${entityType}"`)) { + // don't add config if the entity is never explicitly used or referenced + continue; + } + applyDefaultFieldsForEntityConfig(config, entityType); + } + + return config; +} + +function applyDefaultFieldsForEntityConfig(config, entityType: string) { + if (!config.data['entity:' + entityType]) { + config.data['entity:' + entityType] = {}; + } + const entityConfig = config.data['entity:' + entityType]; + + const hardCodedConfig = DEFAULT_ENTITIES[entityType]; + + entityConfig.label = entityConfig.label ?? hardCodedConfig.label; + entityConfig.labelPlural = + entityConfig.labelPlural ?? hardCodedConfig.labelPlural; + entityConfig.icon = entityConfig.icon ?? hardCodedConfig.icon; + entityConfig.toStringAttributes = + entityConfig.toStringAttributes ?? hardCodedConfig.toStringAttributes; + entityConfig.hasPII = entityConfig.hasPII ?? hardCodedConfig.hasPII; + + entityConfig.attributes = Object.assign( + {}, + hardCodedConfig.attributes, + entityConfig.attributes, + ); +} + +const DEFAULT_ENTITIES = { + User: { + toStringAttributes: ['name'], + icon: 'user', + label: 'User', + labelPlural: 'Users', + hasPII: true, + attributes: { + name: { + dataType: 'string', + label: 'Username', + validators: { + required: true, + uniqueId: 'User', + }, + }, + phone: { + dataType: 'string', + label: 'Contact', + }, + }, + }, + EducationalMaterial: { + attributes: { + child: { + dataType: 'entity', + additional: 'Child', + entityReferenceRole: 'composite', + }, + date: { + dataType: 'date', + label: 'Date', + defaultValue: { + mode: 'dynamic', + value: '$now', + }, + }, + materialType: { + label: 'Material', + dataType: 'configurable-enum', + additional: 'materials', + validators: { + required: true, + }, + }, + materialAmount: { + dataType: 'number', + label: 'Amount', + defaultValue: { + mode: 'static', + value: 1, + }, + validators: { + required: true, + }, + }, + description: { + dataType: 'string', + label: 'Description', + }, + }, + }, + Todo: { + toStringAttributes: ['subject'], + icon: 'check', + label: 'Task', + labelPlural: 'Tasks', + hasPII: true, + attributes: { + subject: { + dataType: 'string', + label: 'Subject', + showInDetailsView: true, + }, + description: { + dataType: 'long-text', + showInDetailsView: true, + label: 'Description', + }, + deadline: { + dataType: 'date-only', + showInDetailsView: true, + anonymize: 'retain', + label: 'Deadline', + }, + startDate: { + dataType: 'date-only', + description: + 'When you are planning to start work so that you keep enough time before the actual hard deadline.', + showInDetailsView: true, + anonymize: 'retain', + label: 'Start Date', + }, + assignedTo: { + label: 'Assigned to', + dataType: 'entity', + isArray: true, + additional: 'User', + showInDetailsView: true, + defaultValue: { + mode: 'dynamic', + value: '$current_user', + }, + anonymize: 'retain', + }, + relatedEntities: { + dataType: 'entity', + isArray: true, + label: 'Related Records', + additional: ['Child', 'School', 'RecurringActivity'], + entityReferenceRole: 'composite', + showInDetailsView: true, + anonymize: 'retain', + }, + repetitionInterval: { + label: 'repeats', + additional: [ + { + label: 'every week', + interval: { amount: 1, unit: 'week' }, + }, + { + label: 'every month', + interval: { amount: 1, unit: 'month' }, + }, + ], + showInDetailsView: true, + anonymize: 'retain', + }, + completed: { + label: 'completed', + viewComponent: 'DisplayTodoCompletion', + anonymize: 'retain', + }, + }, + }, + School: { + toStringAttributes: ['name'], + icon: 'university', + label: 'School', + labelPlural: 'Schools', + color: '#9E9D24', + attributes: { + name: { + dataType: 'string', + label: 'Name', + validators: { + required: true, + }, + }, + }, + }, + Aser: { + hasPII: true, + attributes: { + child: { + dataType: 'entity', + additional: 'Child', + entityReferenceRole: 'composite', + }, + date: { + dataType: 'date', + label: 'Date', + defaultValue: { + mode: 'dynamic', + value: '$now', + }, + anonymize: 'retain-anonymized', + }, + hindi: { + label: 'Hindi', + dataType: 'configurable-enum', + additional: 'reading-levels', + }, + bengali: { + label: 'Bengali', + dataType: 'configurable-enum', + additional: 'reading-levels', + }, + english: { + label: 'English', + dataType: 'configurable-enum', + additional: 'reading-levels', + }, + math: { + label: 'Math', + dataType: 'configurable-enum', + additional: 'math-levels', + }, + remarks: { + dataType: 'string', + label: 'Remarks', + }, + }, + }, + // TODO: the "bmi" column in view configs needs to be adapted manually (this is only used at a single client, however) + HealthCheck: { + hasPII: true, + attributes: { + child: { + dataType: 'entity', + additional: 'Child', + entityReferenceRole: 'composite', + anonymize: 'retain', + }, + date: { + dataType: 'date', + label: 'Date', + defaultValue: { + mode: 'dynamic', + value: '$now', + }, + anonymize: 'retain-anonymized', + }, + height: { + dataType: 'number', + label: 'Height [cm]', + viewComponent: 'DisplayUnit', + additional: 'cm', + }, + weight: { + dataType: 'number', + label: 'Weight [kg]', + viewComponent: 'DisplayUnit', + additional: 'kg', + }, + }, + }, + ChildSchoolRelation: { + hasPII: true, + attributes: { + childId: { + dataType: 'entity', + additional: 'Child', + entityReferenceRole: 'composite', + validators: { + required: true, + }, + anonymize: 'retain', + label: 'Child', + }, + schoolId: { + dataType: 'entity', + additional: 'School', + entityReferenceRole: 'aggregate', + validators: { + required: true, + }, + anonymize: 'retain', + label: 'School', + }, + schoolClass: { + dataType: 'string', + label: 'Class', + }, + start: { + dataType: 'date-only', + label: 'Start date', + description: 'The date a child joins a school', + anonymize: 'retain', + }, + end: { + dataType: 'date-only', + label: 'End date', + description: 'The date of a child leaving the school', + anonymize: 'retain', + }, + result: { + dataType: 'number', + label: 'Result', + viewComponent: 'DisplayPercentage', + editComponent: 'EditNumber', + validators: { + min: 0, + max: 100, + }, + }, + }, + }, + Child: { + label: 'Participant', + labelPlural: 'Participants', + toStringAttributes: ['name'], + icon: 'child', + color: '#1565C0', + blockComponent: 'ChildBlock', + hasPII: true, + attributes: { + name: { + dataType: 'string', + label: 'Name', + validators: { + required: true, + }, + }, + projectNumber: { + dataType: 'string', + label: 'Project Number', + labelShort: 'PN', + searchable: true, + anonymize: 'retain', + }, + dateOfBirth: { + dataType: 'date-with-age', + label: 'Date of birth', + labelShort: 'DoB', + anonymize: 'retain-anonymized', + }, + center: { + dataType: 'configurable-enum', + additional: 'center', + label: 'Center', + anonymize: 'retain', + }, + gender: { + dataType: 'configurable-enum', + label: 'Gender', + additional: 'genders', + anonymize: 'retain', + }, + admissionDate: { + dataType: 'date-only', + label: 'Admission', + anonymize: 'retain-anonymized', + }, + status: { + dataType: 'string', + label: 'Status', + }, + dropoutDate: { + dataType: 'date-only', + label: 'Dropout Date', + anonymize: 'retain-anonymized', + }, + dropoutType: { + dataType: 'string', + label: 'Dropout Type', + anonymize: 'retain', + }, + dropoutRemarks: { + dataType: 'string', + label: 'Dropout remarks', + }, + photo: { + dataType: 'file', + label: 'Photo', + editComponent: 'EditPhoto', + }, + phone: { + dataType: 'string', + label: 'Phone Number', + }, + }, + }, + RecurringActivity: { + toStringAttributes: ['title'], + label: 'Recurring Activity', + labelPlural: 'Recurring Activities', + color: '#00838F', + route: 'attendance/recurring-activity', + attributes: { + title: { + dataType: 'string', + label: 'Title', + validators: { + required: true, + }, + }, + type: { + label: 'Type', + dataType: 'configurable-enum', + additional: 'interaction-type', + }, + participants: { + label: 'Participants', + dataType: 'entity', + isArray: true, + additional: 'Child', + }, + linkedGroups: { + label: 'Groups', + dataType: 'entity', + isArray: true, + additional: 'School', + }, + excludedParticipants: { + label: 'Excluded Participants', + dataType: 'entity', + isArray: true, + additional: 'Child', + }, + assignedTo: { + label: 'Assigned user(s)', + dataType: 'entity', + isArray: true, + additional: 'User', + }, + }, + }, + Note: { + toStringAttributes: ['subject'], + label: 'Note', + labelPlural: 'Notes', + hasPII: true, + attributes: { + children: { + label: 'Children', + dataType: 'entity', + isArray: true, + additional: 'Child', + entityReferenceRole: 'composite', + editComponent: 'EditAttendance', + anonymize: 'retain', + }, + childrenAttendance: { + dataType: 'event-attendance-map', + anonymize: 'retain', + }, + date: { + label: 'Date', + dataType: 'date-only', + defaultValue: { + mode: 'dynamic', + value: '$now', + }, + anonymize: 'retain', + }, + subject: { + dataType: 'string', + label: 'Subject', + }, + text: { + dataType: 'long-text', + label: 'Notes', + }, + authors: { + label: 'SW', + dataType: 'entity', + isArray: true, + additional: 'User', + defaultValue: { + mode: 'dynamic', + value: '$current_user', + }, + anonymize: 'retain', + }, + category: { + label: 'Category', + dataType: 'configurable-enum', + additional: 'interaction-type', + anonymize: 'retain', + }, + attachment: { + label: 'Attachment', + dataType: 'file', + }, + relatesTo: { + dataType: 'entity', + additional: 'RecurringActivity', + anonymize: 'retain', + }, + relatedEntities: { + label: 'Related Records', + dataType: 'entity', + isArray: true, + // by default no additional relatedEntities can be linked apart from children and schools, overwrite this in config to display (e.g. additional: "ChildSchoolRelation") + additional: undefined, + anonymize: 'retain', + }, + schools: { + label: 'Groups', + dataType: 'entity', + isArray: true, + additional: 'School', + entityReferenceRole: 'composite', + anonymize: 'retain', + }, + warningLevel: { + label: 'Status', + dataType: 'configurable-enum', + additional: 'warning-levels', + anonymize: 'retain', + }, + }, + }, + // entity HistoricalEntityData is only used in very few clients + /* --> manually check & migrate instead of bloating up the config everywhere: + "123-kimue": "matching", + "ashayen": "matching", + "demo2": "matching", + "demo-health": "matching", + "helgo": "matching", + "sambodh": "matching", + "yojak-gsp": "matching" + */ +};