@open-mercato/shared 0.4.2-canary-c02407ff85

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 (324) hide show
  1. package/build.mjs +101 -0
  2. package/dist/index.js +1 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/lib/api/crud.js +47 -0
  5. package/dist/lib/api/crud.js.map +7 -0
  6. package/dist/lib/api/scoped.js +140 -0
  7. package/dist/lib/api/scoped.js.map +7 -0
  8. package/dist/lib/auth/jwt.js +34 -0
  9. package/dist/lib/auth/jwt.js.map +7 -0
  10. package/dist/lib/auth/server.js +157 -0
  11. package/dist/lib/auth/server.js.map +7 -0
  12. package/dist/lib/boolean.js +22 -0
  13. package/dist/lib/boolean.js.map +7 -0
  14. package/dist/lib/bootstrap/appResolver.js +43 -0
  15. package/dist/lib/bootstrap/appResolver.js.map +7 -0
  16. package/dist/lib/bootstrap/dynamicLoader.js +108 -0
  17. package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
  18. package/dist/lib/bootstrap/factory.js +59 -0
  19. package/dist/lib/bootstrap/factory.js.map +7 -0
  20. package/dist/lib/bootstrap/index.js +11 -0
  21. package/dist/lib/bootstrap/index.js.map +7 -0
  22. package/dist/lib/bootstrap/types.js +1 -0
  23. package/dist/lib/bootstrap/types.js.map +7 -0
  24. package/dist/lib/cache/segments.js +36 -0
  25. package/dist/lib/cache/segments.js.map +7 -0
  26. package/dist/lib/cli/progress.js +46 -0
  27. package/dist/lib/cli/progress.js.map +7 -0
  28. package/dist/lib/commands/command-bus.js +285 -0
  29. package/dist/lib/commands/command-bus.js.map +7 -0
  30. package/dist/lib/commands/customFieldSnapshots.js +66 -0
  31. package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
  32. package/dist/lib/commands/helpers.js +98 -0
  33. package/dist/lib/commands/helpers.js.map +7 -0
  34. package/dist/lib/commands/index.js +8 -0
  35. package/dist/lib/commands/index.js.map +7 -0
  36. package/dist/lib/commands/operationMetadata.js +32 -0
  37. package/dist/lib/commands/operationMetadata.js.map +7 -0
  38. package/dist/lib/commands/registry.js +43 -0
  39. package/dist/lib/commands/registry.js.map +7 -0
  40. package/dist/lib/commands/scope.js +44 -0
  41. package/dist/lib/commands/scope.js.map +7 -0
  42. package/dist/lib/commands/types.js +8 -0
  43. package/dist/lib/commands/types.js.map +7 -0
  44. package/dist/lib/crud/cache-stats.js +98 -0
  45. package/dist/lib/crud/cache-stats.js.map +7 -0
  46. package/dist/lib/crud/cache.js +175 -0
  47. package/dist/lib/crud/cache.js.map +7 -0
  48. package/dist/lib/crud/custom-fields-client.js +52 -0
  49. package/dist/lib/crud/custom-fields-client.js.map +7 -0
  50. package/dist/lib/crud/custom-fields.js +467 -0
  51. package/dist/lib/crud/custom-fields.js.map +7 -0
  52. package/dist/lib/crud/errors.js +24 -0
  53. package/dist/lib/crud/errors.js.map +7 -0
  54. package/dist/lib/crud/exporters.js +154 -0
  55. package/dist/lib/crud/exporters.js.map +7 -0
  56. package/dist/lib/crud/factory.js +1311 -0
  57. package/dist/lib/crud/factory.js.map +7 -0
  58. package/dist/lib/crud/types.js +1 -0
  59. package/dist/lib/crud/types.js.map +7 -0
  60. package/dist/lib/custom-fields/normalize.js +36 -0
  61. package/dist/lib/custom-fields/normalize.js.map +7 -0
  62. package/dist/lib/data/engine.js +396 -0
  63. package/dist/lib/data/engine.js.map +7 -0
  64. package/dist/lib/db/escapeLikePattern.js +5 -0
  65. package/dist/lib/db/escapeLikePattern.js.map +7 -0
  66. package/dist/lib/db/mikro.js +82 -0
  67. package/dist/lib/db/mikro.js.map +7 -0
  68. package/dist/lib/di/container.js +94 -0
  69. package/dist/lib/di/container.js.map +7 -0
  70. package/dist/lib/email/send.js +12 -0
  71. package/dist/lib/email/send.js.map +7 -0
  72. package/dist/lib/encryption/aes.js +58 -0
  73. package/dist/lib/encryption/aes.js.map +7 -0
  74. package/dist/lib/encryption/customFieldValues.js +49 -0
  75. package/dist/lib/encryption/customFieldValues.js.map +7 -0
  76. package/dist/lib/encryption/entityFields.js +26 -0
  77. package/dist/lib/encryption/entityFields.js.map +7 -0
  78. package/dist/lib/encryption/entityIds.js +80 -0
  79. package/dist/lib/encryption/entityIds.js.map +7 -0
  80. package/dist/lib/encryption/find.js +45 -0
  81. package/dist/lib/encryption/find.js.map +7 -0
  82. package/dist/lib/encryption/indexDoc.js +69 -0
  83. package/dist/lib/encryption/indexDoc.js.map +7 -0
  84. package/dist/lib/encryption/kms.js +282 -0
  85. package/dist/lib/encryption/kms.js.map +7 -0
  86. package/dist/lib/encryption/subscriber.js +330 -0
  87. package/dist/lib/encryption/subscriber.js.map +7 -0
  88. package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
  89. package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
  90. package/dist/lib/encryption/toggles.js +18 -0
  91. package/dist/lib/encryption/toggles.js.map +7 -0
  92. package/dist/lib/entities/naming.js +9 -0
  93. package/dist/lib/entities/naming.js.map +7 -0
  94. package/dist/lib/entities/system-entities.js +43 -0
  95. package/dist/lib/entities/system-entities.js.map +7 -0
  96. package/dist/lib/frontend/organizationEvents.js +41 -0
  97. package/dist/lib/frontend/organizationEvents.js.map +7 -0
  98. package/dist/lib/frontend/useOrganizationScope.js +32 -0
  99. package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
  100. package/dist/lib/hotkeys/index.js +128 -0
  101. package/dist/lib/hotkeys/index.js.map +7 -0
  102. package/dist/lib/i18n/app-dictionaries.js +17 -0
  103. package/dist/lib/i18n/app-dictionaries.js.map +7 -0
  104. package/dist/lib/i18n/config.js +7 -0
  105. package/dist/lib/i18n/config.js.map +7 -0
  106. package/dist/lib/i18n/context.js +50 -0
  107. package/dist/lib/i18n/context.js.map +7 -0
  108. package/dist/lib/i18n/server.js +68 -0
  109. package/dist/lib/i18n/server.js.map +7 -0
  110. package/dist/lib/i18n/translate.js +45 -0
  111. package/dist/lib/i18n/translate.js.map +7 -0
  112. package/dist/lib/indexers/error-log.js +82 -0
  113. package/dist/lib/indexers/error-log.js.map +7 -0
  114. package/dist/lib/indexers/status-log.js +80 -0
  115. package/dist/lib/indexers/status-log.js.map +7 -0
  116. package/dist/lib/lib/auth/jwt.js +34 -0
  117. package/dist/lib/lib/auth/jwt.js.map +7 -0
  118. package/dist/lib/lib/auth/server.js +77 -0
  119. package/dist/lib/lib/auth/server.js.map +7 -0
  120. package/dist/lib/lib/email/send.js +12 -0
  121. package/dist/lib/lib/email/send.js.map +7 -0
  122. package/dist/lib/lib/i18n/config.js +7 -0
  123. package/dist/lib/lib/i18n/config.js.map +7 -0
  124. package/dist/lib/lib/i18n/context.js +31 -0
  125. package/dist/lib/lib/i18n/context.js.map +7 -0
  126. package/dist/lib/lib/utils.js +9 -0
  127. package/dist/lib/lib/utils.js.map +7 -0
  128. package/dist/lib/location/countries.js +68 -0
  129. package/dist/lib/location/countries.js.map +7 -0
  130. package/dist/lib/modules/index.js +6 -0
  131. package/dist/lib/modules/index.js.map +7 -0
  132. package/dist/lib/modules/registry.js +18 -0
  133. package/dist/lib/modules/registry.js.map +7 -0
  134. package/dist/lib/openapi/crud.js +137 -0
  135. package/dist/lib/openapi/crud.js.map +7 -0
  136. package/dist/lib/openapi/generator.js +1131 -0
  137. package/dist/lib/openapi/generator.js.map +7 -0
  138. package/dist/lib/openapi/index.js +10 -0
  139. package/dist/lib/openapi/index.js.map +7 -0
  140. package/dist/lib/openapi/sanitize.js +110 -0
  141. package/dist/lib/openapi/sanitize.js.map +7 -0
  142. package/dist/lib/openapi/types.js +1 -0
  143. package/dist/lib/openapi/types.js.map +7 -0
  144. package/dist/lib/profiler/index.js +258 -0
  145. package/dist/lib/profiler/index.js.map +7 -0
  146. package/dist/lib/query/engine.js +729 -0
  147. package/dist/lib/query/engine.js.map +7 -0
  148. package/dist/lib/query/join-utils.js +195 -0
  149. package/dist/lib/query/join-utils.js.map +7 -0
  150. package/dist/lib/query/types.js +9 -0
  151. package/dist/lib/query/types.js.map +7 -0
  152. package/dist/lib/search/config.js +32 -0
  153. package/dist/lib/search/config.js.map +7 -0
  154. package/dist/lib/search/tokenize.js +34 -0
  155. package/dist/lib/search/tokenize.js.map +7 -0
  156. package/dist/lib/slugify.js +24 -0
  157. package/dist/lib/slugify.js.map +7 -0
  158. package/dist/lib/testing/bootstrap.js +51 -0
  159. package/dist/lib/testing/bootstrap.js.map +7 -0
  160. package/dist/lib/testing/index.js +17 -0
  161. package/dist/lib/testing/index.js.map +7 -0
  162. package/dist/lib/testing/renderWithProviders.js +15 -0
  163. package/dist/lib/testing/renderWithProviders.js.map +7 -0
  164. package/dist/lib/url.js +12 -0
  165. package/dist/lib/url.js.map +7 -0
  166. package/dist/lib/utils.js +13 -0
  167. package/dist/lib/utils.js.map +7 -0
  168. package/dist/lib/version.js +7 -0
  169. package/dist/lib/version.js.map +7 -0
  170. package/dist/modules/dashboard/widgets.js +1 -0
  171. package/dist/modules/dashboard/widgets.js.map +7 -0
  172. package/dist/modules/dsl.js +30 -0
  173. package/dist/modules/dsl.js.map +7 -0
  174. package/dist/modules/entities/kinds.js +22 -0
  175. package/dist/modules/entities/kinds.js.map +7 -0
  176. package/dist/modules/entities/options.js +26 -0
  177. package/dist/modules/entities/options.js.map +7 -0
  178. package/dist/modules/entities/validation.js +102 -0
  179. package/dist/modules/entities/validation.js.map +7 -0
  180. package/dist/modules/entities/validators.js +88 -0
  181. package/dist/modules/entities/validators.js.map +7 -0
  182. package/dist/modules/entities.js +1 -0
  183. package/dist/modules/entities.js.map +7 -0
  184. package/dist/modules/navigation/sidebarPreferences.js +50 -0
  185. package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
  186. package/dist/modules/perspectives/types.js +1 -0
  187. package/dist/modules/perspectives/types.js.map +7 -0
  188. package/dist/modules/registry.js +96 -0
  189. package/dist/modules/registry.js.map +7 -0
  190. package/dist/modules/search.js +15 -0
  191. package/dist/modules/search.js.map +7 -0
  192. package/dist/modules/vector.js +1 -0
  193. package/dist/modules/vector.js.map +7 -0
  194. package/dist/modules/widgets/injection-loader.js +180 -0
  195. package/dist/modules/widgets/injection-loader.js.map +7 -0
  196. package/dist/modules/widgets/injection.js +1 -0
  197. package/dist/modules/widgets/injection.js.map +7 -0
  198. package/dist/security/features.js +23 -0
  199. package/dist/security/features.js.map +7 -0
  200. package/dist/types/pg.d.js +1 -0
  201. package/dist/types/pg.d.js.map +7 -0
  202. package/dist/types/react-email.d.js +1 -0
  203. package/dist/types/react-email.d.js.map +7 -0
  204. package/dist/types/resend.d.js +1 -0
  205. package/dist/types/resend.d.js.map +7 -0
  206. package/jest.config.cjs +22 -0
  207. package/package.json +88 -0
  208. package/src/index.ts +0 -0
  209. package/src/lib/api/__tests__/scoped.test.ts +38 -0
  210. package/src/lib/api/crud.ts +59 -0
  211. package/src/lib/api/scoped.ts +239 -0
  212. package/src/lib/auth/jwt.ts +39 -0
  213. package/src/lib/auth/server.ts +199 -0
  214. package/src/lib/boolean.ts +17 -0
  215. package/src/lib/bootstrap/appResolver.ts +85 -0
  216. package/src/lib/bootstrap/dynamicLoader.ts +177 -0
  217. package/src/lib/bootstrap/factory.ts +108 -0
  218. package/src/lib/bootstrap/index.ts +23 -0
  219. package/src/lib/bootstrap/types.ts +31 -0
  220. package/src/lib/cache/segments.ts +56 -0
  221. package/src/lib/cli/progress.ts +55 -0
  222. package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
  223. package/src/lib/commands/__tests__/helpers.test.ts +42 -0
  224. package/src/lib/commands/command-bus.ts +349 -0
  225. package/src/lib/commands/customFieldSnapshots.ts +86 -0
  226. package/src/lib/commands/helpers.ts +143 -0
  227. package/src/lib/commands/index.ts +4 -0
  228. package/src/lib/commands/operationMetadata.ts +40 -0
  229. package/src/lib/commands/registry.ts +46 -0
  230. package/src/lib/commands/scope.ts +59 -0
  231. package/src/lib/commands/types.ts +63 -0
  232. package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
  233. package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
  234. package/src/lib/crud/cache-stats.ts +127 -0
  235. package/src/lib/crud/cache.ts +205 -0
  236. package/src/lib/crud/custom-fields-client.ts +54 -0
  237. package/src/lib/crud/custom-fields.ts +607 -0
  238. package/src/lib/crud/errors.ts +23 -0
  239. package/src/lib/crud/exporters.ts +188 -0
  240. package/src/lib/crud/factory.ts +1622 -0
  241. package/src/lib/crud/types.ts +29 -0
  242. package/src/lib/custom-fields/normalize.ts +45 -0
  243. package/src/lib/data/engine.ts +562 -0
  244. package/src/lib/db/escapeLikePattern.ts +2 -0
  245. package/src/lib/db/mikro.ts +100 -0
  246. package/src/lib/di/container.ts +105 -0
  247. package/src/lib/email/send.ts +18 -0
  248. package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
  249. package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
  250. package/src/lib/encryption/aes.ts +64 -0
  251. package/src/lib/encryption/customFieldValues.ts +67 -0
  252. package/src/lib/encryption/entityFields.ts +39 -0
  253. package/src/lib/encryption/entityIds.ts +107 -0
  254. package/src/lib/encryption/find.ts +81 -0
  255. package/src/lib/encryption/indexDoc.ts +104 -0
  256. package/src/lib/encryption/kms.ts +337 -0
  257. package/src/lib/encryption/subscriber.ts +416 -0
  258. package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
  259. package/src/lib/encryption/toggles.ts +15 -0
  260. package/src/lib/entities/naming.ts +6 -0
  261. package/src/lib/entities/system-entities.ts +43 -0
  262. package/src/lib/frontend/organizationEvents.ts +55 -0
  263. package/src/lib/frontend/useOrganizationScope.ts +30 -0
  264. package/src/lib/hotkeys/index.ts +168 -0
  265. package/src/lib/i18n/app-dictionaries.ts +18 -0
  266. package/src/lib/i18n/config.ts +4 -0
  267. package/src/lib/i18n/context.tsx +66 -0
  268. package/src/lib/i18n/server.ts +74 -0
  269. package/src/lib/i18n/translate.ts +54 -0
  270. package/src/lib/indexers/error-log.ts +106 -0
  271. package/src/lib/indexers/status-log.ts +119 -0
  272. package/src/lib/lib/auth/jwt.ts +39 -0
  273. package/src/lib/lib/auth/server.ts +94 -0
  274. package/src/lib/lib/email/send.ts +18 -0
  275. package/src/lib/lib/i18n/config.ts +4 -0
  276. package/src/lib/lib/i18n/context.tsx +38 -0
  277. package/src/lib/lib/utils.ts +6 -0
  278. package/src/lib/location/countries.ts +97 -0
  279. package/src/lib/modules/index.ts +1 -0
  280. package/src/lib/modules/registry.ts +18 -0
  281. package/src/lib/openapi/crud.ts +218 -0
  282. package/src/lib/openapi/generator.ts +1311 -0
  283. package/src/lib/openapi/index.ts +4 -0
  284. package/src/lib/openapi/sanitize.ts +137 -0
  285. package/src/lib/openapi/types.ts +79 -0
  286. package/src/lib/profiler/index.ts +371 -0
  287. package/src/lib/query/__tests__/engine.test.ts +274 -0
  288. package/src/lib/query/engine.ts +837 -0
  289. package/src/lib/query/join-utils.ts +238 -0
  290. package/src/lib/query/types.ts +121 -0
  291. package/src/lib/search/config.ts +49 -0
  292. package/src/lib/search/tokenize.ts +45 -0
  293. package/src/lib/slugify.ts +28 -0
  294. package/src/lib/testing/bootstrap.ts +124 -0
  295. package/src/lib/testing/index.ts +15 -0
  296. package/src/lib/testing/renderWithProviders.tsx +31 -0
  297. package/src/lib/url.ts +12 -0
  298. package/src/lib/utils.ts +17 -0
  299. package/src/lib/version.ts +5 -0
  300. package/src/modules/__tests__/dsl.test.ts +35 -0
  301. package/src/modules/__tests__/registry.test.ts +300 -0
  302. package/src/modules/dashboard/widgets.ts +57 -0
  303. package/src/modules/dsl.ts +32 -0
  304. package/src/modules/entities/__tests__/validation.test.ts +52 -0
  305. package/src/modules/entities/kinds.ts +20 -0
  306. package/src/modules/entities/options.ts +36 -0
  307. package/src/modules/entities/validation.ts +118 -0
  308. package/src/modules/entities/validators.ts +93 -0
  309. package/src/modules/entities.ts +102 -0
  310. package/src/modules/navigation/sidebarPreferences.ts +62 -0
  311. package/src/modules/perspectives/types.ts +40 -0
  312. package/src/modules/registry.ts +249 -0
  313. package/src/modules/search.ts +325 -0
  314. package/src/modules/vector.ts +122 -0
  315. package/src/modules/widgets/__tests__/injection.test.ts +48 -0
  316. package/src/modules/widgets/injection-loader.ts +235 -0
  317. package/src/modules/widgets/injection.ts +120 -0
  318. package/src/security/features.ts +22 -0
  319. package/src/types/pg.d.ts +2 -0
  320. package/src/types/react-email.d.ts +2 -0
  321. package/src/types/resend.d.ts +2 -0
  322. package/tsconfig.build.json +11 -0
  323. package/tsconfig.json +9 -0
  324. package/watch.mjs +6 -0
@@ -0,0 +1,59 @@
1
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
+ import { env } from 'process'
4
+
5
+ function logScopeViolation(
6
+ ctx: CommandRuntimeContext,
7
+ expected: string,
8
+ actual: string | null
9
+ ): void {
10
+ try {
11
+ const requestInfo =
12
+ ctx.request && typeof ctx.request === 'object'
13
+ ? {
14
+ method: (ctx.request as Request).method ?? undefined,
15
+ url: (ctx.request as Request).url ?? undefined,
16
+ }
17
+ : null
18
+ const scope = ctx.organizationScope
19
+ ? {
20
+ selectedId: ctx.organizationScope.selectedId ?? null,
21
+ tenantId: ctx.organizationScope.tenantId ?? null,
22
+ allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
23
+ ? ctx.organizationScope.allowedIds.length
24
+ : null,
25
+ filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
26
+ ? ctx.organizationScope.filterIds.length
27
+ : null,
28
+ }
29
+ : null
30
+ if (env.NODE_ENV !== 'test') {
31
+ console.warn('[scope] Forbidden organization scope mismatch detected', {
32
+ expectedId: expected,
33
+ actualId: actual,
34
+ userId: ctx.auth?.sub ?? null,
35
+ actorTenantId: ctx.auth?.tenantId ?? null,
36
+ actorOrganizationId: ctx.auth?.orgId ?? null,
37
+ selectedOrganizationId: ctx.selectedOrganizationId ?? null,
38
+ organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
39
+ scope,
40
+ request: requestInfo,
41
+ })
42
+ }
43
+ } catch {
44
+ // best-effort logging
45
+ }
46
+ }
47
+
48
+ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {
49
+ // Superadmins with global org access can operate on any organization's records
50
+ if (ctx.auth?.isSuperAdmin === true || ctx.organizationScope?.allowedIds === null) {
51
+ return
52
+ }
53
+
54
+ const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
55
+ if (currentOrg && currentOrg !== organizationId) {
56
+ logScopeViolation(ctx, organizationId, currentOrg)
57
+ throw new CrudHttpError(403, { error: 'Forbidden' })
58
+ }
59
+ }
@@ -0,0 +1,63 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import { randomUUID } from 'crypto'
3
+ import type { AuthContext } from '../auth/server'
4
+ import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
5
+
6
+ export type CommandRuntimeContext = {
7
+ container: AwilixContainer
8
+ auth: AuthContext | null
9
+ organizationScope: OrganizationScope | null
10
+ selectedOrganizationId: string | null
11
+ organizationIds: string[] | null
12
+ request?: Request
13
+ }
14
+
15
+ export type CommandLogMetadata = {
16
+ tenantId?: string | null
17
+ organizationId?: string | null
18
+ actorUserId?: string | null
19
+ actionLabel?: string | null
20
+ resourceKind?: string | null
21
+ resourceId?: string | null
22
+ undoToken?: string | null
23
+ payload?: unknown
24
+ snapshotBefore?: unknown
25
+ snapshotAfter?: unknown
26
+ changes?: Record<string, unknown> | null
27
+ context?: Record<string, unknown> | null
28
+ }
29
+
30
+ export type CommandExecuteResult<TResult> = {
31
+ result: TResult
32
+ logEntry: any | null
33
+ }
34
+
35
+ export type CommandLogBuilderArgs<TInput, TResult> = {
36
+ input: TInput
37
+ result: TResult
38
+ ctx: CommandRuntimeContext
39
+ snapshots: {
40
+ before?: unknown
41
+ after?: unknown
42
+ }
43
+ }
44
+
45
+ export interface CommandHandler<TInput = unknown, TResult = unknown> {
46
+ readonly id: string
47
+ readonly isUndoable?: boolean
48
+ prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null
49
+ execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
50
+ buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
51
+ captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
52
+ undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: any }): Promise<void> | void
53
+ }
54
+
55
+ export type CommandExecutionOptions<TInput> = {
56
+ input: TInput
57
+ ctx: CommandRuntimeContext
58
+ metadata?: CommandLogMetadata | null
59
+ }
60
+
61
+ export function defaultUndoToken(): string {
62
+ return randomUUID()
63
+ }
@@ -0,0 +1,333 @@
1
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
2
+ import { z } from 'zod'
3
+
4
+ // ---- Mocks ----
5
+ const mockEventBus = { emitEvent: jest.fn() }
6
+
7
+ type Rec = { id: string; organizationId: string; tenantId: string; title?: string; isDone?: boolean; deletedAt?: Date | null }
8
+ let db: Record<string, Rec>
9
+ let idSeq = 1
10
+ let commandBus: { execute: jest.Mock }
11
+
12
+ const em = {
13
+ create: (_cls: any, data: any) => ({ ...data, id: `id-${idSeq++}` }),
14
+ persistAndFlush: async (entity: Rec) => { db[entity.id] = { ...(db[entity.id] || {} as any), ...entity } },
15
+ findOne: async (_entity: any, where: any) => (em.getRepository(_entity).findOne(where) as any),
16
+ getRepository: (_cls: any) => ({
17
+ find: async (where: any) => Object.values(db).filter((r) => {
18
+ const orgClause = where.organizationId
19
+ const matchesOrg = !orgClause
20
+ ? true
21
+ : (typeof orgClause === 'object' && Array.isArray(orgClause.$in))
22
+ ? orgClause.$in.includes(r.organizationId)
23
+ : r.organizationId === orgClause
24
+ const matchesTenant = !where.tenantId || r.tenantId === where.tenantId
25
+ const matchesDeleted = where.deletedAt === null ? !r.deletedAt : true
26
+ return matchesOrg && matchesTenant && matchesDeleted
27
+ }),
28
+ findOne: async (where: any) => Object.values(db).find((r) => {
29
+ if (r.id !== where.id) return false
30
+ const orgClause = where.organizationId
31
+ const matchesOrg = !orgClause
32
+ ? true
33
+ : (typeof orgClause === 'object' && Array.isArray(orgClause.$in))
34
+ ? orgClause.$in.includes(r.organizationId)
35
+ : r.organizationId === orgClause
36
+ return matchesOrg && r.tenantId === where.tenantId
37
+ }) || null,
38
+ removeAndFlush: async (entity: Rec) => { delete db[entity.id] },
39
+ }),
40
+ }
41
+
42
+ const queryEngine = {
43
+ query: jest.fn(async (_entityId: any, _q: any) => ({ items: [{ id: 'id-1', title: 'A', is_done: false, organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenant_id: '123e4567-e89b-12d3-a456-426614174000' }], total: 1 })),
44
+ }
45
+
46
+ const mockDataEngine = {
47
+ __pendingSideEffects: [] as any[],
48
+ createOrmEntity: jest.fn(async ({ entity, data }: any) => {
49
+ const created = em.create(entity, data)
50
+ await em.persistAndFlush(created as any)
51
+ return created
52
+ }),
53
+ updateOrmEntity: jest.fn(async ({ entity, where, apply }: any) => {
54
+ const current = await (em.getRepository(entity).findOne(where) as any)
55
+ if (!current) return null
56
+ await apply(current)
57
+ await em.persistAndFlush(current)
58
+ return current
59
+ }),
60
+ deleteOrmEntity: jest.fn(async ({ entity, where, soft, softDeleteField }: any) => {
61
+ const repo = em.getRepository(entity)
62
+ const current = await (repo.findOne(where) as any)
63
+ if (!current) return null
64
+ if (soft !== false) { (current as any)[softDeleteField || 'deletedAt'] = new Date(); await em.persistAndFlush(current) }
65
+ else await repo.removeAndFlush(current)
66
+ return current
67
+ }),
68
+ setCustomFields: jest.fn(async (args: any) => {
69
+ await (setRecordCustomFields as any)(em, args)
70
+ }),
71
+ emitOrmEntityEvent: jest.fn(async (_entry: any) => {}),
72
+ markOrmEntityChange: jest.fn(function (this: any, entry: any) {
73
+ if (!entry || !entry.entity) return
74
+ this.__pendingSideEffects.push(entry)
75
+ }),
76
+ flushOrmEntityChanges: jest.fn(async function (this: any) {
77
+ while (this.__pendingSideEffects.length > 0) {
78
+ const next = this.__pendingSideEffects.shift()
79
+ await this.emitOrmEntityEvent(next)
80
+ }
81
+ }),
82
+ }
83
+
84
+ const accessLogService = {
85
+ log: jest.fn(async () => {}),
86
+ }
87
+
88
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
89
+ createRequestContainer: async () => ({
90
+ resolve: (name: string) => ({
91
+ em,
92
+ queryEngine,
93
+ eventBus: mockEventBus,
94
+ dataEngine: mockDataEngine,
95
+ accessLogService,
96
+ commandBus,
97
+ } as any)[name],
98
+ })
99
+ }))
100
+
101
+ jest.mock('@open-mercato/shared/lib/auth/server', () => {
102
+ const auth = { sub: 'u1', orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000', roles: ['admin'] }
103
+ return {
104
+ getAuthFromCookies: async () => auth,
105
+ getAuthFromRequest: async () => auth,
106
+ }
107
+ })
108
+
109
+ const setRecordCustomFields = jest.fn(async () => {})
110
+ jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
111
+ setRecordCustomFields: (...args: any[]) => (setRecordCustomFields as any)(...args)
112
+ }))
113
+
114
+ // Fake entity class
115
+ class Todo {}
116
+
117
+ describe('CRUD Factory', () => {
118
+ beforeEach(() => {
119
+ db = {}
120
+ idSeq = 1
121
+ jest.clearAllMocks()
122
+ accessLogService.log.mockClear()
123
+ mockDataEngine.__pendingSideEffects = []
124
+ commandBus = {
125
+ execute: jest.fn(async () => ({ result: {}, logEntry: { id: 'log-1' } })),
126
+ }
127
+ })
128
+
129
+ const querySchema = z.object({
130
+ page: z.coerce.number().default(1),
131
+ pageSize: z.coerce.number().default(50),
132
+ sortField: z.string().default('id'),
133
+ sortDir: z.enum(['asc','desc']).default('asc'),
134
+ format: z.enum(['csv', 'json', 'xml', 'markdown']).optional(),
135
+ })
136
+ const createSchema = z.object({ title: z.string().min(1), is_done: z.boolean().optional().default(false), cf_priority: z.number().optional() })
137
+ const updateSchema = z.object({ id: z.string(), title: z.string().optional(), is_done: z.boolean().optional(), cf_priority: z.number().optional() })
138
+
139
+ const route = makeCrudRoute({
140
+ metadata: { GET: { requireAuth: true }, POST: { requireAuth: true }, PUT: { requireAuth: true }, DELETE: { requireAuth: true } },
141
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
142
+ events: { module: 'example', entity: 'todo', persistent: true },
143
+ indexer: { entityType: 'example.todo' },
144
+ list: {
145
+ schema: querySchema,
146
+ entityId: 'example.todo',
147
+ fields: ['id','title','is_done'],
148
+ sortFieldMap: { id: 'id', title: 'title' },
149
+ buildFilters: () => ({} as any),
150
+ transformItem: (i: any) => ({ id: i.id, title: i.title, is_done: i.is_done }),
151
+ allowCsv: true,
152
+ csv: { headers: ['id','title','is_done'], row: (t) => [t.id, t.title, t.is_done ? '1' : '0'], filename: 'todos.csv' }
153
+ },
154
+ create: {
155
+ schema: createSchema,
156
+ mapToEntity: (input) => ({ title: (input as any).title, isDone: !!(input as any).is_done }),
157
+ customFields: { enabled: true, entityId: 'example.todo', pickPrefixed: true },
158
+ },
159
+ update: {
160
+ schema: updateSchema,
161
+ applyToEntity: (e, input) => { if ((input as any).title !== undefined) (e as any).title = (input as any).title; if ((input as any).is_done !== undefined) (e as any).isDone = !!(input as any).is_done },
162
+ customFields: { enabled: true, entityId: 'example.todo', pickPrefixed: true },
163
+ },
164
+ del: { idFrom: 'query', softDelete: true },
165
+ })
166
+
167
+ it('GET returns JSON list via QueryEngine', async () => {
168
+ const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
169
+ expect(res.status).toBe(200)
170
+ const body = await res.json()
171
+ expect(body.items.length).toBe(1)
172
+ expect(body.total).toBe(1)
173
+ expect(body.items[0]).toEqual({ id: 'id-1', title: 'A', is_done: false })
174
+ expect(accessLogService.log).toHaveBeenCalledTimes(1)
175
+ expect(accessLogService.log).toHaveBeenCalledWith(expect.objectContaining({
176
+ resourceKind: 'example.todo',
177
+ resourceId: 'id-1',
178
+ accessType: 'read',
179
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
180
+ organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
181
+ actorUserId: 'u1',
182
+ fields: expect.arrayContaining(['id', 'title', 'is_done']),
183
+ context: expect.objectContaining({
184
+ resultCount: 1,
185
+ accessType: 'read',
186
+ queryKeys: expect.arrayContaining(['page', 'pageSize', 'sortField', 'sortDir']),
187
+ }),
188
+ }))
189
+ })
190
+
191
+ it('GET returns CSV when format=csv', async () => {
192
+ const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc&format=csv'))
193
+ expect(res.headers.get('content-type')).toContain('text/csv')
194
+ expect(res.headers.get('content-disposition')).toContain('todos.csv')
195
+ const text = await res.text()
196
+ expect(text.split('\n')[0]).toBe('id,title,is_done')
197
+ expect(accessLogService.log).toHaveBeenCalledTimes(1)
198
+ })
199
+
200
+ it('GET returns JSON export when format=json', async () => {
201
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
202
+ expect(res.headers.get('content-type')).toContain('application/json')
203
+ expect(res.headers.get('content-disposition')).toContain('todo.json')
204
+ const text = await res.text()
205
+ const parsed = JSON.parse(text)
206
+ expect(Array.isArray(parsed)).toBe(true)
207
+ expect(parsed[0]).toEqual({ id: 'id-1', title: 'A', is_done: '0' })
208
+ })
209
+
210
+ it('GET returns XML export when format=xml', async () => {
211
+ const res = await route.GET(new Request('http://x/api/example/todos?format=xml'))
212
+ expect(res.headers.get('content-type')).toContain('application/xml')
213
+ expect(res.headers.get('content-disposition')).toContain('todo.xml')
214
+ const text = await res.text()
215
+ expect(text).toContain('<records>')
216
+ expect(text).toContain('<id>id-1</id>')
217
+ expect(text).toContain('<title>A</title>')
218
+ })
219
+
220
+ it('GET returns Markdown export when format=markdown', async () => {
221
+ const res = await route.GET(new Request('http://x/api/example/todos?format=markdown'))
222
+ expect(res.headers.get('content-type')).toContain('text/markdown')
223
+ expect(res.headers.get('content-disposition')).toContain('todo.md')
224
+ const text = await res.text()
225
+ const lines = text.split('\n')
226
+ expect(lines[0]).toBe('| id | title | is_done |')
227
+ expect(lines[2]).toContain('id-1')
228
+ })
229
+
230
+ it('GET returns full export when exportScope=full', async () => {
231
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
232
+ expect(res.headers.get('content-type')).toContain('application/json')
233
+ expect(res.headers.get('content-disposition')).toContain('todo_full.json')
234
+ const text = await res.text()
235
+ const parsed = JSON.parse(text)
236
+ expect(Array.isArray(parsed)).toBe(true)
237
+ const row = parsed[0]
238
+ expect(row).toMatchObject({
239
+ Id: 'id-1',
240
+ Title: 'A',
241
+ 'Is Done': false,
242
+ 'Organization Id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
243
+ 'Tenant Id': '123e4567-e89b-12d3-a456-426614174000',
244
+ })
245
+ })
246
+
247
+ it('POST creates entity, saves custom fields, emits created event', async () => {
248
+ const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'B', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
249
+ expect(res.status).toBe(201)
250
+ const data = await res.json()
251
+ expect(data.id).toBeDefined()
252
+ // CF saved
253
+ expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
254
+ // Event + indexer delegated to data engine
255
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
256
+ const createdCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
257
+ expect(createdCall).toBeDefined()
258
+ const [createdArgs] = createdCall!
259
+ expect(createdArgs.action).toBe('created')
260
+ expect(createdArgs.identifiers.id).toBe(data.id)
261
+ expect(createdArgs.events?.module).toBe('example')
262
+ expect(createdArgs.events?.entity).toBe('todo')
263
+ expect(createdArgs.indexer?.entityType).toBe('example.todo')
264
+ // Entity in db
265
+ const rec = db[data.id]
266
+ expect(rec).toBeTruthy()
267
+ expect(rec.title).toBe('B')
268
+ expect(rec.isDone).toBe(true)
269
+ })
270
+
271
+ it('PUT updates entity, saves custom fields, emits updated event', async () => {
272
+ // Seed
273
+ const created = em.create(Todo, { title: 'X', organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000' }) as Rec
274
+ // Force UUID id to satisfy validation
275
+ created.id = '123e4567-e89b-12d3-a456-426614174001'
276
+ await em.persistAndFlush(created)
277
+ const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
278
+ expect(res.status).toBe(200)
279
+ expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
280
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
281
+ const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
282
+ expect(updatedCall).toBeDefined()
283
+ const [updatedArgs] = updatedCall!
284
+ expect(updatedArgs.action).toBe('updated')
285
+ expect(updatedArgs.identifiers.id).toBe(created.id)
286
+ expect(updatedArgs.indexer?.entityType).toBe('example.todo')
287
+ expect(db[created.id].title).toBe('X2')
288
+ })
289
+
290
+ it('DELETE soft-deletes entity and emits deleted event', async () => {
291
+ const created = em.create(Todo, { title: 'Y', organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', tenantId: '123e4567-e89b-12d3-a456-426614174000' }) as Rec
292
+ created.id = '123e4567-e89b-12d3-a456-426614174002'
293
+ await em.persistAndFlush(created)
294
+ const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}`, { method: 'DELETE' }))
295
+ expect(res.status).toBe(200)
296
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
297
+ const deletedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
298
+ expect(deletedCall).toBeDefined()
299
+ const [deletedArgs] = deletedCall!
300
+ expect(deletedArgs.action).toBe('deleted')
301
+ expect(deletedArgs.identifiers.id).toBe(created.id)
302
+ expect(deletedArgs.indexer?.entityType).toBe('example.todo')
303
+ expect(db[created.id].deletedAt).toBeInstanceOf(Date)
304
+ })
305
+
306
+ it('DELETE command route uses domain-specific ids from result when emitting events', async () => {
307
+ const indexedId = 'line-999'
308
+ commandBus.execute.mockResolvedValue({
309
+ result: { lineId: indexedId, orderId: 'order-1' },
310
+ logEntry: { id: 'log-1' },
311
+ })
312
+ const commandRoute = makeCrudRoute({
313
+ metadata: { DELETE: { requireAuth: true } },
314
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
315
+ indexer: { entityType: 'example.todo' },
316
+ actions: {
317
+ delete: {
318
+ commandId: 'example.todo.delete',
319
+ schema: z.any(),
320
+ response: () => ({ ok: true }),
321
+ },
322
+ },
323
+ })
324
+ const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', { method: 'DELETE', body: JSON.stringify({}), headers: { 'content-type': 'application/json' } }))
325
+ expect(res.status).toBe(200)
326
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
327
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
328
+ const [deletedArgs] = mockDataEngine.emitOrmEntityEvent.mock.calls[0]!
329
+ expect(deletedArgs.action).toBe('deleted')
330
+ expect(deletedArgs.identifiers.id).toBe(indexedId)
331
+ expect(deletedArgs.indexer?.entityType).toBe('example.todo')
332
+ })
333
+ })
@@ -0,0 +1,150 @@
1
+ import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, splitCustomFieldPayload, loadCustomFieldValues } from '../custom-fields'
2
+ import { encryptWithAesGcm } from '../../encryption/aes'
3
+
4
+ const mockEntityManager = (defs: any[]) => ({
5
+ find: jest.fn().mockResolvedValue(defs),
6
+ })
7
+
8
+ describe('buildCustomFieldFiltersFromQuery', () => {
9
+ const definitions = [
10
+ {
11
+ id: 'def-fashion-color',
12
+ key: 'color',
13
+ kind: 'text',
14
+ entityId: 'catalog:product',
15
+ configJson: { fieldset: 'fashion' },
16
+ },
17
+ {
18
+ id: 'def-shared-material',
19
+ key: 'material',
20
+ kind: 'text',
21
+ entityId: 'catalog:product',
22
+ configJson: {},
23
+ },
24
+ ]
25
+
26
+ it('generates filters for matching definitions regardless of fieldset when none specified', async () => {
27
+ const em = mockEntityManager(definitions)
28
+ const filters = await buildCustomFieldFiltersFromQuery({
29
+ entityIds: ['catalog:product'],
30
+ query: { cf_color: 'blue' },
31
+ em: em as any,
32
+ tenantId: 'tenant-1',
33
+ })
34
+ expect(em.find).toHaveBeenCalled()
35
+ expect(filters).toEqual({ 'cf:color': 'blue' })
36
+ })
37
+
38
+ it('restricts filters to the requested fieldset code', async () => {
39
+ const em = mockEntityManager(definitions)
40
+ const filters = await buildCustomFieldFiltersFromQuery({
41
+ entityIds: ['catalog:product'],
42
+ query: { cf_color: 'blue' },
43
+ em: em as any,
44
+ tenantId: 'tenant-1',
45
+ fieldset: 'fashion',
46
+ })
47
+ expect(filters).toEqual({ 'cf:color': 'blue' })
48
+ const emptyFilters = await buildCustomFieldFiltersFromQuery({
49
+ entityIds: ['catalog:product'],
50
+ query: { cf_color: 'blue', cf_material: 'cotton' },
51
+ em: em as any,
52
+ tenantId: 'tenant-1',
53
+ fieldset: 'tech',
54
+ })
55
+ expect(emptyFilters).toEqual({})
56
+ })
57
+ })
58
+
59
+ describe('splitCustomFieldPayload', () => {
60
+ it('pulls values from customValues map', () => {
61
+ const raw = {
62
+ name: 'Channel',
63
+ customValues: {
64
+ api_url: 'https://example.dev',
65
+ priority: 5,
66
+ },
67
+ }
68
+ expect(splitCustomFieldPayload(raw)).toEqual({
69
+ base: { name: 'Channel' },
70
+ custom: { api_url: 'https://example.dev', priority: 5 },
71
+ })
72
+ })
73
+
74
+ it('maps array based customFields entries', () => {
75
+ const raw = {
76
+ customFields: [
77
+ { key: 'api_url', value: 'https://example.dev' },
78
+ { key: '', value: 'ignored' },
79
+ { key: 'notes', value: null },
80
+ ],
81
+ code: 'demo',
82
+ }
83
+ expect(splitCustomFieldPayload(raw)).toEqual({
84
+ base: { code: 'demo' },
85
+ custom: {
86
+ api_url: 'https://example.dev',
87
+ notes: null,
88
+ },
89
+ })
90
+ })
91
+ })
92
+
93
+ describe('extractAllCustomFieldEntries', () => {
94
+ it('merges entries from customValues maps and customFields objects', () => {
95
+ const item = {
96
+ customValues: { api_url: 'https://fws1.api', priority: 5 },
97
+ customFields: { notes: 'memo' },
98
+ other: 'value',
99
+ }
100
+ expect(extractAllCustomFieldEntries(item)).toEqual({
101
+ cf_api_url: 'https://fws1.api',
102
+ cf_priority: 5,
103
+ cf_notes: 'memo',
104
+ })
105
+ })
106
+
107
+ it('reads entries from customFields arrays and keeps existing cf_* keys', () => {
108
+ const item = {
109
+ customFields: [
110
+ { key: 'api_url', value: 'https://onet.pl' },
111
+ { key: '', value: 'skip-me' },
112
+ { key: 'notes' },
113
+ ],
114
+ cf_existing: 'foo',
115
+ }
116
+ expect(extractAllCustomFieldEntries(item)).toEqual({
117
+ cf_api_url: 'https://onet.pl',
118
+ cf_notes: undefined,
119
+ cf_existing: 'foo',
120
+ })
121
+ })
122
+ })
123
+
124
+ describe('loadCustomFieldValues (encryption)', () => {
125
+ it('decrypts encrypted custom field payloads when definitions mark them encrypted', async () => {
126
+ const dek = Buffer.alloc(32, 2).toString('base64')
127
+ const encrypted = encryptWithAesGcm(JSON.stringify('secret-note'), dek).value
128
+ const em = {
129
+ find: jest.fn().mockImplementation((_, where) => {
130
+ if ((where as any).recordId) {
131
+ return Promise.resolve([
132
+ { recordId: 'rec-1', fieldKey: 'note', organizationId: null, tenantId: 'tenant-1', valueText: encrypted, valueMultiline: null, valueInt: null, valueFloat: null, valueBool: null, deletedAt: null },
133
+ ])
134
+ }
135
+ return Promise.resolve([
136
+ { key: 'note', entityId: 'demo:entity', organizationId: null, tenantId: 'tenant-1', kind: 'text', configJson: { encrypted: true }, isActive: true },
137
+ ])
138
+ }),
139
+ }
140
+ const mockService = { isEnabled: () => true, getDek: async () => ({ key: dek }) }
141
+ const values = await loadCustomFieldValues({
142
+ em: em as any,
143
+ entityId: 'demo:entity',
144
+ recordIds: ['rec-1'],
145
+ tenantIdByRecord: { 'rec-1': 'tenant-1' },
146
+ encryptionService: mockService as any,
147
+ })
148
+ expect(values['rec-1'].cf_note).toBe('secret-note')
149
+ })
150
+ })