@solidxai/core 0.1.11-beta.0 → 0.1.11-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CLAUDE.md +26 -0
  2. package/dist/controllers/service.controller.d.ts +1 -0
  3. package/dist/controllers/service.controller.d.ts.map +1 -1
  4. package/dist/controllers/service.controller.js +2 -2
  5. package/dist/controllers/service.controller.js.map +1 -1
  6. package/dist/helpers/field-crud-managers/IntFieldCrudManager.d.ts.map +1 -1
  7. package/dist/helpers/field-crud-managers/IntFieldCrudManager.js +4 -1
  8. package/dist/helpers/field-crud-managers/IntFieldCrudManager.js.map +1 -1
  9. package/dist/helpers/solid-registry.d.ts +2 -1
  10. package/dist/helpers/solid-registry.d.ts.map +1 -1
  11. package/dist/helpers/solid-registry.js.map +1 -1
  12. package/dist/repository/scheduled-job.repository.d.ts +3 -0
  13. package/dist/repository/scheduled-job.repository.d.ts.map +1 -1
  14. package/dist/repository/scheduled-job.repository.js +60 -0
  15. package/dist/repository/scheduled-job.repository.js.map +1 -1
  16. package/dist/seeders/module-metadata-seeder.service.d.ts +37 -2
  17. package/dist/seeders/module-metadata-seeder.service.d.ts.map +1 -1
  18. package/dist/seeders/module-metadata-seeder.service.js +1071 -232
  19. package/dist/seeders/module-metadata-seeder.service.js.map +1 -1
  20. package/dist/seeders/seed-data/solid-core-metadata.json +7 -0
  21. package/dist/services/action-metadata.service.d.ts +7 -0
  22. package/dist/services/action-metadata.service.d.ts.map +1 -1
  23. package/dist/services/action-metadata.service.js +76 -3
  24. package/dist/services/action-metadata.service.js.map +1 -1
  25. package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.d.ts.map +1 -1
  26. package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.js +6 -5
  27. package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.js.map +1 -1
  28. package/dist/services/locale.service.d.ts +14 -0
  29. package/dist/services/locale.service.d.ts.map +1 -1
  30. package/dist/services/locale.service.js +83 -0
  31. package/dist/services/locale.service.js.map +1 -1
  32. package/dist/services/role-metadata.service.d.ts +5 -0
  33. package/dist/services/role-metadata.service.d.ts.map +1 -1
  34. package/dist/services/role-metadata.service.js +95 -33
  35. package/dist/services/role-metadata.service.js.map +1 -1
  36. package/dist/services/view-metadata.service.d.ts +6 -0
  37. package/dist/services/view-metadata.service.d.ts.map +1 -1
  38. package/dist/services/view-metadata.service.js +70 -8
  39. package/dist/services/view-metadata.service.js.map +1 -1
  40. package/dist/subscribers/audit.subscriber.d.ts +2 -0
  41. package/dist/subscribers/audit.subscriber.d.ts.map +1 -1
  42. package/dist/subscribers/audit.subscriber.js +25 -6
  43. package/dist/subscribers/audit.subscriber.js.map +1 -1
  44. package/dist/subscribers/computed-entity-field.subscriber.d.ts.map +1 -1
  45. package/dist/subscribers/computed-entity-field.subscriber.js +1 -0
  46. package/dist/subscribers/computed-entity-field.subscriber.js.map +1 -1
  47. package/dist/subscribers/created-by-updated-by.subscriber.d.ts.map +1 -1
  48. package/dist/subscribers/created-by-updated-by.subscriber.js.map +1 -1
  49. package/dist/subscribers/field-metadata.subscriber.d.ts.map +1 -1
  50. package/dist/subscribers/field-metadata.subscriber.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/controllers/service.controller.ts +3 -2
  53. package/src/helpers/field-crud-managers/IntFieldCrudManager.ts +4 -1
  54. package/src/helpers/solid-registry.ts +7 -1
  55. package/src/repository/scheduled-job.repository.ts +73 -0
  56. package/src/seeders/module-metadata-seeder.service.ts +1295 -264
  57. package/src/seeders/seed-data/solid-core-metadata.json +7 -0
  58. package/src/services/action-metadata.service.ts +87 -2
  59. package/src/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.ts +10 -5
  60. package/src/services/locale.service.ts +111 -3
  61. package/src/services/role-metadata.service.ts +112 -49
  62. package/src/services/view-metadata.service.ts +83 -9
  63. package/src/subscribers/audit.subscriber.ts +34 -14
  64. package/src/subscribers/computed-entity-field.subscriber.ts +1 -0
  65. package/src/subscribers/created-by-updated-by.subscriber.ts +3 -0
  66. package/src/subscribers/field-metadata.subscriber.ts +3 -0
@@ -5244,6 +5244,13 @@
5244
5244
  }
5245
5245
  ]
5246
5246
  },
5247
+ "locales": [
5248
+ {
5249
+ "locale": "en",
5250
+ "displayName": "English",
5251
+ "isDefault": true
5252
+ }
5253
+ ],
5247
5254
  "permissions": [
5248
5255
  "mcp:invoke",
5249
5256
  "agent:invoke",
@@ -1,4 +1,4 @@
1
- import { Injectable } from '@nestjs/common';
1
+ import { Injectable, Logger } from '@nestjs/common';
2
2
  import { ModuleRef } from "@nestjs/core";
3
3
  import { InjectEntityManager } from '@nestjs/typeorm';
4
4
  import { CRUDService } from 'src/services/crud.service';
@@ -10,6 +10,79 @@ import { ActionMetadata } from '../entities/action-metadata.entity';
10
10
 
11
11
  @Injectable()
12
12
  export class ActionMetadataService extends CRUDService<ActionMetadata> {
13
+ private readonly logger = new Logger(ActionMetadataService.name);
14
+ private readonly comparableFields = [
15
+ 'name',
16
+ 'displayName',
17
+ 'type',
18
+ 'domain',
19
+ 'context',
20
+ 'customComponent',
21
+ 'customIsModal',
22
+ 'serverEndpoint',
23
+ 'module',
24
+ 'model',
25
+ 'view',
26
+ ] as const;
27
+ private readonly relationCompareFields = new Set(['module', 'model', 'view']);
28
+ private readonly jsonCompareFields = new Set(['domain', 'context']);
29
+
30
+ private normalizeJsonFieldValue(value: any): any {
31
+ if (typeof value === 'string') {
32
+ const trimmedValue = value.trim();
33
+ if (
34
+ (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
35
+ (trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))
36
+ ) {
37
+ try {
38
+ return this.normalizeJsonFieldValue(JSON.parse(trimmedValue));
39
+ } catch {
40
+ return value;
41
+ }
42
+ }
43
+ return value;
44
+ }
45
+
46
+ if (Array.isArray(value)) {
47
+ return value.map((item) => this.normalizeJsonFieldValue(item));
48
+ }
49
+
50
+ if (value && typeof value === 'object') {
51
+ return Object.keys(value)
52
+ .sort()
53
+ .reduce((result, key) => {
54
+ result[key] = this.normalizeJsonFieldValue(value[key]);
55
+ return result;
56
+ }, {} as Record<string, any>);
57
+ }
58
+
59
+ return value;
60
+ }
61
+
62
+ private getCanonicalJsonFieldString(value: any): string {
63
+ return JSON.stringify(this.normalizeJsonFieldValue(value) ?? null);
64
+ }
65
+
66
+ private hasChanges(existingSolidAction: ActionMetadata, updateSolidActionDto: any): boolean {
67
+ return this.comparableFields.some((key) => {
68
+ const value = updateSolidActionDto[key];
69
+ if (typeof value === 'undefined') {
70
+ return false;
71
+ }
72
+
73
+ if (this.relationCompareFields.has(key)) {
74
+ const relationValue = value as any;
75
+ return (existingSolidAction as any)[key]?.id !== relationValue?.id;
76
+ }
77
+
78
+ if (this.jsonCompareFields.has(key)) {
79
+ return this.getCanonicalJsonFieldString((existingSolidAction as any)[key]) !== this.getCanonicalJsonFieldString(value);
80
+ }
81
+
82
+ return (existingSolidAction as any)[key] !== value;
83
+ });
84
+ }
85
+
13
86
  constructor(
14
87
  @InjectEntityManager()
15
88
  readonly entityManager: EntityManager,
@@ -37,11 +110,23 @@ export class ActionMetadataService extends CRUDService<ActionMetadata> {
37
110
  const existingSolidAction = await this.repo.findOne({
38
111
  where: {
39
112
  name: updateSolidActionDto.name
40
- }
113
+ },
114
+ relations: {
115
+ module: true,
116
+ model: true,
117
+ view: true,
118
+ },
41
119
  })
42
120
 
43
121
  // if found
44
122
  if (existingSolidAction) {
123
+ const hasChanges = this.hasChanges(existingSolidAction, updateSolidActionDto);
124
+
125
+ if (!hasChanges) {
126
+ this.logger.debug(`Skipping action upsert for ${updateSolidActionDto.name}; no changes detected.`);
127
+ return existingSolidAction;
128
+ }
129
+
45
130
  const updatedSolidActionDto = { ...existingSolidAction, ...updateSolidActionDto };
46
131
  return this.repo.save(updatedSolidActionDto);
47
132
  }
@@ -45,7 +45,12 @@ export class AlphaNumExternalIdComputationProvider<T extends CommonEntity> imple
45
45
  }
46
46
  }
47
47
 
48
- const uniqueCode = await this.generateUniqueExternalId(resolvedPrefix, codeLength, computedFieldMetadata.fieldName, entityName);
48
+ // Use the originating transaction's EntityManager so the uniqueness check runs on the
49
+ // active connection. Falls back to the injected manager only when no event context is
50
+ // available (e.g. direct invocation outside a TypeORM lifecycle event).
51
+ const manager = eventContext?.manager ?? this.entityManager;
52
+
53
+ const uniqueCode = await this.generateUniqueExternalId(manager, resolvedPrefix, codeLength, computedFieldMetadata.fieldName, entityName);
49
54
  const finalExternalId = resolvedPrefix ? `${resolvedPrefix}-${uniqueCode}` : uniqueCode;
50
55
 
51
56
  triggerEntity[computedFieldMetadata.fieldName] = finalExternalId;
@@ -60,8 +65,8 @@ export class AlphaNumExternalIdComputationProvider<T extends CommonEntity> imple
60
65
  return result;
61
66
  }
62
67
 
63
- private async isExternalIdUnique(externalId: string, fieldName: string, entityName: string): Promise<boolean> {
64
- const count = await this.entityManager.count(entityName as any,
68
+ private async isExternalIdUnique(manager: EntityManager, externalId: string, fieldName: string, entityName: string): Promise<boolean> {
69
+ const count = await manager.count(entityName as any,
65
70
  {
66
71
  where: { [fieldName]: externalId },
67
72
  }
@@ -69,7 +74,7 @@ export class AlphaNumExternalIdComputationProvider<T extends CommonEntity> imple
69
74
  return count === 0;
70
75
  }
71
76
 
72
- private async generateUniqueExternalId(resolvedPrefix: string, codeLength: number, fieldName: string, entityName: string): Promise<string> {
77
+ private async generateUniqueExternalId(manager: EntityManager, resolvedPrefix: string, codeLength: number, fieldName: string, entityName: string): Promise<string> {
73
78
  const maxAttempts = 10;
74
79
 
75
80
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -78,7 +83,7 @@ export class AlphaNumExternalIdComputationProvider<T extends CommonEntity> imple
78
83
 
79
84
  const fullId = resolvedPrefix ? `${resolvedPrefix}-${newId}` : newId;
80
85
 
81
- const isUnique = await this.isExternalIdUnique(fullId, fieldName, entityName);
86
+ const isUnique = await this.isExternalIdUnique(manager, fullId, fieldName, entityName);
82
87
 
83
88
  if (isUnique) {
84
89
  return newId;
@@ -1,7 +1,7 @@
1
- import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
1
+ import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
2
2
  import { ModuleRef } from "@nestjs/core";
3
3
  import { InjectEntityManager } from '@nestjs/typeorm';
4
- import { EntityManager } from 'typeorm';
4
+ import { EntityManager, In, IsNull, Not } from 'typeorm';
5
5
 
6
6
  import { Locale } from 'src/entities/locale.entity';
7
7
  import { SolidRegistry } from 'src/helpers/solid-registry';
@@ -31,7 +31,115 @@ export class LocaleService extends CRUDService<Locale> implements OnApplicationB
31
31
  // Load the security rules from the database
32
32
  this.loadLocales();
33
33
  }
34
- async loadLocales() {
34
+
35
+ private async assertCanSetAsDefault(currentLocaleId?: number) {
36
+ const defaultLocales = await this.repo.find({
37
+ where: {
38
+ isDefault: true,
39
+ } as any,
40
+ });
41
+
42
+ const conflictingDefaultLocale = defaultLocales.find(
43
+ (locale) => locale.id !== currentLocaleId
44
+ );
45
+
46
+ if (conflictingDefaultLocale) {
47
+ throw new BadRequestException(
48
+ `Locale "${conflictingDefaultLocale.displayName}" is already marked as default. Mark it as non-default first.`
49
+ );
50
+ }
51
+ }
52
+
53
+ override async create(createDto: any, files: Express.Multer.File[] = [], solidRequestContext: any = {}): Promise<Locale> {
54
+ if (createDto?.isDefault === true) {
55
+ await this.assertCanSetAsDefault();
56
+ }
57
+
58
+ const savedLocale = await super.create(createDto, files, solidRequestContext);
59
+ await this.loadLocales();
60
+ return savedLocale;
61
+ }
62
+
63
+ override async insertMany(createDtos: any[], filesArray: Express.Multer.File[][] = [], solidRequestContext: any = {}): Promise<Locale[]> {
64
+ const defaultLocalesInPayload = (createDtos || []).filter((dto) => dto?.isDefault === true);
65
+
66
+ if (defaultLocalesInPayload.length > 1) {
67
+ throw new BadRequestException(`Only one locale can be marked as default.`);
68
+ }
69
+
70
+ if (defaultLocalesInPayload.length === 1) {
71
+ await this.assertCanSetAsDefault();
72
+ }
73
+
74
+ const savedLocales = await super.insertMany(createDtos, filesArray, solidRequestContext);
75
+ await this.loadLocales();
76
+ return savedLocales;
77
+ }
78
+
79
+ override async update(id: number, updateDto: any, files: Express.Multer.File[] = [], isPartialUpdate: boolean = false, solidRequestContext: any = {}, isUpdate: boolean = false): Promise<Locale> {
80
+ if (updateDto?.isDefault === true) {
81
+ await this.assertCanSetAsDefault(id);
82
+ }
83
+
84
+ const savedLocale = await super.update(id, updateDto, files, isPartialUpdate, solidRequestContext, isUpdate);
85
+ await this.loadLocales();
86
+ return savedLocale;
87
+ }
88
+
89
+ override async recover(id: number, solidRequestContext: any = {}) {
90
+ const localeToRecover = await this.repo.findOne({
91
+ where: {
92
+ id,
93
+ deletedAt: Not(IsNull()),
94
+ } as any,
95
+ withDeleted: true,
96
+ });
97
+
98
+ if (localeToRecover?.isDefault) {
99
+ await this.assertCanSetAsDefault(id);
100
+ }
101
+
102
+ const recoveredLocale = await super.recover(id, solidRequestContext);
103
+ await this.loadLocales();
104
+ return recoveredLocale;
105
+ }
106
+
107
+ override async recoverMany(ids: number[], solidRequestContext: any = {}) {
108
+ const localesToRecover = await this.repo.find({
109
+ where: {
110
+ id: In(ids),
111
+ deletedAt: Not(IsNull()),
112
+ } as any,
113
+ withDeleted: true,
114
+ });
115
+
116
+ const defaultLocalesToRecover = localesToRecover.filter((locale) => locale.isDefault);
117
+ if (defaultLocalesToRecover.length > 1) {
118
+ throw new BadRequestException(`Only one locale can be marked as default.`);
119
+ }
120
+
121
+ if (defaultLocalesToRecover.length === 1) {
122
+ await this.assertCanSetAsDefault(defaultLocalesToRecover[0].id);
123
+ }
124
+
125
+ const recoveredLocales = await super.recoverMany(ids, solidRequestContext);
126
+ await this.loadLocales();
127
+ return recoveredLocales;
128
+ }
129
+
130
+ override async delete(id: number, solidRequestContext: any = {}) {
131
+ const deletedLocale = await super.delete(id, solidRequestContext);
132
+ await this.loadLocales();
133
+ return deletedLocale;
134
+ }
135
+
136
+ override async deleteMany(ids: number[], solidRequestContext: any = {}) {
137
+ const deletedLocales = await super.deleteMany(ids, solidRequestContext);
138
+ await this.loadLocales();
139
+ return deletedLocales;
140
+ }
141
+
142
+ async loadLocales() {
35
143
  const locales = await this.repo.find();
36
144
  this.logger.debug(`Loaded ${locales.length} locales into registry`);
37
145
  this.solidRegistry.registerlocales(locales);
@@ -8,12 +8,14 @@ import { ERROR_MESSAGES } from 'src/constants/error-messages';
8
8
  import { PermissionMetadataRepository } from 'src/repository/permission-metadata.repository';
9
9
  import { RoleMetadataRepository } from 'src/repository/role-metadata.repository';
10
10
  import { CreateRoleMetadataDto } from '../dtos/create-role-metadata.dto';
11
+ import { ModuleMetadata } from '../entities/module-metadata.entity';
11
12
  import { PermissionMetadata } from '../entities/permission-metadata.entity';
12
13
  import { RoleMetadata } from '../entities/role-metadata.entity';
13
14
 
14
15
  @Injectable()
15
16
  export class RoleMetadataService extends CRUDService<RoleMetadata> {
16
17
  private readonly logger = new Logger(RoleMetadataService.name);
18
+ private readonly permissionAssignmentBatchSize = 500;
17
19
 
18
20
  constructor(
19
21
  @InjectEntityManager()
@@ -64,13 +66,16 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
64
66
  for (let id = 0; id < roles.length; id++) {
65
67
  try {
66
68
  const roleObj = roles[id];
69
+ const module = await this.resolveRoleModule(roleObj);
67
70
  // this.logger.log(`Resolving role: ${JSON.stringify(roleObj)}`);
68
71
 
69
72
  const existingRole = await this.repo.findOne({
70
73
  where: {
71
74
  name: roleObj.name,
72
75
  },
73
- relations: {},
76
+ relations: {
77
+ module: true,
78
+ },
74
79
  });
75
80
 
76
81
  // Create only if not existing already.
@@ -86,9 +91,13 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
86
91
  }
87
92
 
88
93
  // const role = this.repo.create({ ...roleObj, permissions });
89
- const role = this.repo.create({ ...roleObj });
94
+ const role = this.repo.create({ ...roleObj, ...(module ? { module } : {}) });
90
95
  await this.repo.save(role);
91
96
  } else {
97
+ if (module && existingRole.module?.id !== module.id) {
98
+ existingRole.module = module;
99
+ await this.repo.save(existingRole);
100
+ }
92
101
  /*
93
102
  this.logger.debug(`Role ${roleObj.name} already exists`);
94
103
  const existingPermissions = existingRole.permissions.map(permission => permission.name);
@@ -111,6 +120,22 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
111
120
  }
112
121
  }
113
122
 
123
+ private async resolveRoleModule(roleObj: CreateRoleMetadataDto): Promise<ModuleMetadata | null> {
124
+ if (roleObj.moduleId) {
125
+ return await this.entityManager.getRepository(ModuleMetadata).findOne({
126
+ where: { id: roleObj.moduleId },
127
+ });
128
+ }
129
+
130
+ if (roleObj.moduleUserKey) {
131
+ return await this.entityManager.getRepository(ModuleMetadata).findOne({
132
+ where: { name: roleObj.moduleUserKey },
133
+ });
134
+ }
135
+
136
+ return null;
137
+ }
138
+
114
139
  async addAllPermissionsToRole(roleName: string): Promise<RoleMetadata> {
115
140
  return await this._addPermissionsToRole(roleName, null);
116
141
  }
@@ -126,66 +151,47 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
126
151
  private async _addPermissionsToRole(roleName: string, permissionNames: string[]): Promise<RoleMetadata> {
127
152
  const role = await this.repo.findOne({
128
153
  where: { name: roleName },
129
- relations: {
130
- permissions: true
154
+ select: {
155
+ id: true,
156
+ name: true,
131
157
  }
132
158
  });
133
159
  if (!role) {
134
160
  throw new Error(`Role '${roleName}' not found.`);
135
161
  }
136
162
 
137
- // this.logger.log(`Found role ${roleName}`);
163
+ const joinInfo = this.getRolePermissionJoinInfo();
164
+ const targetPermissions = await this.loadTargetPermissions(permissionNames);
138
165
 
139
- // The new set of permissions which are to be added to this role.
140
- let newPermissions: PermissionMetadata[];
166
+ if (!permissionNames || permissionNames.length === 0) {
167
+ await this.entityManager
168
+ .createQueryBuilder()
169
+ .delete()
170
+ .from(joinInfo.tableName)
171
+ .where(`${joinInfo.roleIdColumn} = :roleId`, { roleId: role.id })
172
+ .execute();
141
173
 
142
- // Load all the specified permissions in the system.
143
- if (permissionNames && permissionNames.length != 0) {
144
- // this.logger.log(`Loading specified permissions.`);
145
-
146
- newPermissions = await this.permissionRepository.find({ where: { name: In(permissionNames) } });
147
- if (newPermissions.length !== permissionNames.length) {
148
- throw new Error(`One or more permissions not found.`);
149
- }
174
+ await this.insertRolePermissionMappings(role.id, targetPermissions.map((permission) => permission.id), joinInfo);
150
175
  }
151
176
  else {
152
- // this.logger.log(`Loading all permissions in system.`);
153
-
154
- // Load all permissions in the system.
155
- // TODO: Do we want to convert this to a paginated query to avoid having to load a very large permissions table into memory?
156
- newPermissions = await this.permissionRepository.find();
157
- if (newPermissions.length == 0) {
158
- throw new Error(`No permissions configured in the system. Did you forget to run the PermissionSeederService?`);
159
- }
160
- }
161
-
162
- // this.logger.log(`Adding ${newPermissions.length} permissions to role ${roleName}.`);
163
-
164
- // if there are already permissions assigned.
165
- if (role.permissions && role.permissions.length > 0) {
166
- for (let i = 0; i < newPermissions.length; i++) {
167
- const newPermission = newPermissions[i];
168
- let newPermissionFound = true;
169
- for (let j = 0; j < role.permissions.length; j++) {
170
- const existingPermission = role.permissions[j];
171
- if (existingPermission.name === newPermission.name) {
172
- newPermissionFound = false;
173
- break;
174
- }
175
- }
176
-
177
- if (newPermissionFound) {
178
- role.permissions.push(newPermission);
179
- }
177
+ const existingRows = await this.entityManager
178
+ .createQueryBuilder()
179
+ .select(joinInfo.permissionIdColumn, 'permissionId')
180
+ .from(joinInfo.tableName, 'role_permission')
181
+ .where(`${joinInfo.roleIdColumn} = :roleId`, { roleId: role.id })
182
+ .getRawMany();
183
+
184
+ const existingPermissionIds = new Set(existingRows.map((row) => Number(row.permissionId)));
185
+ const missingPermissionIds = targetPermissions
186
+ .map((permission) => permission.id)
187
+ .filter((permissionId) => !existingPermissionIds.has(permissionId));
188
+
189
+ if (missingPermissionIds.length > 0) {
190
+ await this.insertRolePermissionMappings(role.id, missingPermissionIds, joinInfo);
180
191
  }
181
-
182
- }
183
- // else we create a new permissions set.
184
- else {
185
- role.permissions = newPermissions;
186
192
  }
187
193
 
188
- return await this.repo.save(role);
194
+ return await this.findRoleByName(roleName);
189
195
  }
190
196
 
191
197
  async removePermissionsFromRole(roleName: string, permissionNames: string[]): Promise<RoleMetadata> {
@@ -220,5 +226,62 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
220
226
  return existingPermission;
221
227
  }
222
228
 
229
+ private async loadTargetPermissions(permissionNames: string[] | null): Promise<PermissionMetadata[]> {
230
+ if (permissionNames && permissionNames.length !== 0) {
231
+ const uniquePermissionNames = [...new Set(permissionNames)];
232
+ const permissions = await this.permissionRepository.find({ where: { name: In(uniquePermissionNames) } });
233
+ if (permissions.length !== uniquePermissionNames.length) {
234
+ throw new Error(`One or more permissions not found.`);
235
+ }
236
+ return permissions;
237
+ }
238
+
239
+ const permissions = await this.permissionRepository.find();
240
+ if (permissions.length === 0) {
241
+ throw new Error(`No permissions configured in the system. Did you forget to run the PermissionSeederService?`);
242
+ }
243
+ return permissions;
244
+ }
245
+
246
+ private getRolePermissionJoinInfo(): { tableName: string; roleIdColumn: string; permissionIdColumn: string } {
247
+ const relation = this.repo.metadata.relations.find((candidate) => candidate.propertyName === 'permissions');
248
+ if (!relation?.junctionEntityMetadata) {
249
+ throw new Error('Role-permission join table metadata not found.');
250
+ }
251
+
252
+ const roleIdColumn = relation.joinColumns[0]?.databaseName;
253
+ const permissionIdColumn = relation.inverseJoinColumns[0]?.databaseName;
254
+ if (!roleIdColumn || !permissionIdColumn) {
255
+ throw new Error('Role-permission join table column metadata not found.');
256
+ }
257
+
258
+ return {
259
+ tableName: relation.junctionEntityMetadata.tableName,
260
+ roleIdColumn,
261
+ permissionIdColumn,
262
+ };
263
+ }
264
+
265
+ private async insertRolePermissionMappings(
266
+ roleId: number,
267
+ permissionIds: number[],
268
+ joinInfo: { tableName: string; roleIdColumn: string; permissionIdColumn: string },
269
+ ): Promise<void> {
270
+ for (let index = 0; index < permissionIds.length; index += this.permissionAssignmentBatchSize) {
271
+ const batch = permissionIds.slice(index, index + this.permissionAssignmentBatchSize);
272
+ await this.entityManager
273
+ .createQueryBuilder()
274
+ .insert()
275
+ .into(joinInfo.tableName)
276
+ .values(
277
+ batch.map((permissionId) => ({
278
+ [joinInfo.roleIdColumn]: roleId,
279
+ [joinInfo.permissionIdColumn]: permissionId,
280
+ })),
281
+ )
282
+ .execute();
283
+ }
284
+ }
285
+
223
286
 
224
287
  }
@@ -21,6 +21,74 @@ import { MenuItemMetadata } from 'src/entities/menu-item-metadata.entity';
21
21
 
22
22
  @Injectable()
23
23
  export class ViewMetadataService extends CRUDService<ViewMetadata> {
24
+ private readonly comparableFields = [
25
+ 'name',
26
+ 'displayName',
27
+ 'type',
28
+ 'context',
29
+ 'layout',
30
+ 'module',
31
+ 'model',
32
+ ] as const;
33
+ private readonly relationCompareFields = new Set(['module', 'model']);
34
+ private readonly jsonCompareFields = new Set(['layout', 'context']);
35
+
36
+ private normalizeJsonFieldValue(value: any): any {
37
+ if (typeof value === 'string') {
38
+ const trimmedValue = value.trim();
39
+ if (
40
+ (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
41
+ (trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))
42
+ ) {
43
+ try {
44
+ return this.normalizeJsonFieldValue(JSON.parse(trimmedValue));
45
+ } catch {
46
+ return value;
47
+ }
48
+ }
49
+ return value;
50
+ }
51
+
52
+ if (Array.isArray(value)) {
53
+ return value.map((item) => this.normalizeJsonFieldValue(item));
54
+ }
55
+
56
+ if (value && typeof value === 'object') {
57
+ return Object.keys(value)
58
+ .sort()
59
+ .reduce((result, key) => {
60
+ result[key] = this.normalizeJsonFieldValue(value[key]);
61
+ return result;
62
+ }, {} as Record<string, any>);
63
+ }
64
+
65
+ return value;
66
+ }
67
+
68
+ private getCanonicalJsonFieldString(value: any): string {
69
+ return JSON.stringify(this.normalizeJsonFieldValue(value) ?? null);
70
+ }
71
+
72
+ private hasChanges(existingSolidView: ViewMetadata, updateSolidViewDto: UpdateViewMetadataDto): boolean {
73
+ return this.comparableFields.some((key) => {
74
+ const value = (updateSolidViewDto as any)[key];
75
+ if (typeof value === 'undefined') {
76
+ return false;
77
+ }
78
+
79
+ if (this.relationCompareFields.has(key)) {
80
+ const relationValue = value as any;
81
+ return (existingSolidView as any)[key]?.id !== relationValue?.id;
82
+ }
83
+
84
+ if (this.jsonCompareFields.has(key)) {
85
+ return this.getCanonicalJsonFieldString((existingSolidView as any)[key]) !== this.getCanonicalJsonFieldString(value);
86
+ }
87
+
88
+ return (existingSolidView as any)[key] !== value;
89
+ });
90
+ }
91
+
24
92
  constructor(
25
93
  readonly actionMetadataService: ActionMetadataService,
26
94
  readonly menuItemMetadataService: MenuItemMetadataService,
@@ -83,15 +151,11 @@ export class ViewMetadataService extends CRUDService<ViewMetadata> {
83
151
  return { records: [], defaultEntityLocaleId: null };
84
152
  }
85
153
 
86
- const defaultLocale = await this.entityManager.getRepository(Locale).findOne({ where: { isDefault: true } });
87
-
88
- let defaultEntityLocaleId: string;
89
- if (entityRecord.localeName === defaultLocale?.locale) {
90
- defaultEntityLocaleId = entityRecord.id;
91
- this.logger.debug(`Editing default locale record with id ${defaultEntityLocaleId}`);
154
+ const defaultEntityLocaleId = entityRecord.defaultEntityLocaleId || entityRecord.id;
155
+ if (entityRecord.defaultEntityLocaleId) {
156
+ this.logger.debug(`Editing translated locale record. Translation root id: ${defaultEntityLocaleId}`);
92
157
  } else {
93
- defaultEntityLocaleId = entityRecord.defaultEntityLocaleId;
94
- this.logger.debug(`Editing non-default locale record. DefaultEntityLocaleId: ${defaultEntityLocaleId}`);
158
+ this.logger.debug(`Editing translation root record with id ${defaultEntityLocaleId}`);
95
159
  }
96
160
 
97
161
  const records = await currentEntityRepository.find({
@@ -438,10 +502,20 @@ export class ViewMetadataService extends CRUDService<ViewMetadata> {
438
502
 
439
503
  async upsert(updateSolidViewDto: UpdateViewMetadataDto) {
440
504
  // First check if module already exists using name
441
- const existingSolidView = await this.findOneByUserKey(updateSolidViewDto.name);
505
+ const existingSolidView = await this.findOneByUserKey(updateSolidViewDto.name, {
506
+ module: true,
507
+ model: true,
508
+ });
442
509
 
443
510
  // if found
444
511
  if (existingSolidView) {
512
+ const hasChanges = this.hasChanges(existingSolidView, updateSolidViewDto);
513
+
514
+ if (!hasChanges) {
515
+ this.logger.debug(`Skipping view upsert for ${updateSolidViewDto.name}; no changes detected.`);
516
+ return existingSolidView;
517
+ }
518
+
445
519
  const updatedSolidViewDto = { ...existingSolidView, ...updateSolidViewDto };
446
520
  return this.repo.save(updatedSolidViewDto);
447
521
  }