@open-mercato/core 0.4.5-develop-0f0e676c72 → 0.4.5-develop-e694581d9f

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 (113) hide show
  1. package/dist/generated/entities/customer_deal/index.js +4 -0
  2. package/dist/generated/entities/customer_deal/index.js.map +2 -2
  3. package/dist/generated/entities/customer_pipeline/index.js +17 -0
  4. package/dist/generated/entities/customer_pipeline/index.js.map +7 -0
  5. package/dist/generated/entities/customer_pipeline_stage/index.js +19 -0
  6. package/dist/generated/entities/customer_pipeline_stage/index.js.map +7 -0
  7. package/dist/generated/entities.ids.generated.js +2 -0
  8. package/dist/generated/entities.ids.generated.js.map +2 -2
  9. package/dist/generated/entity-fields-registry.js +4 -0
  10. package/dist/generated/entity-fields-registry.js.map +2 -2
  11. package/dist/modules/customers/acl.js +2 -0
  12. package/dist/modules/customers/acl.js.map +2 -2
  13. package/dist/modules/customers/api/deals/[id]/route.js +4 -0
  14. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  15. package/dist/modules/customers/api/deals/route.js +12 -0
  16. package/dist/modules/customers/api/deals/route.js.map +2 -2
  17. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -1
  18. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  19. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +69 -0
  20. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +7 -0
  21. package/dist/modules/customers/api/pipeline-stages/route.js +275 -0
  22. package/dist/modules/customers/api/pipeline-stages/route.js.map +7 -0
  23. package/dist/modules/customers/api/pipelines/route.js +245 -0
  24. package/dist/modules/customers/api/pipelines/route.js.map +7 -0
  25. package/dist/modules/customers/backend/config/customers/page.js +2 -0
  26. package/dist/modules/customers/backend/config/customers/page.js.map +2 -2
  27. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +439 -0
  28. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +7 -0
  29. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +17 -0
  30. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +7 -0
  31. package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -1
  32. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  33. package/dist/modules/customers/backend/customers/deals/page.js +35 -1
  34. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  35. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +102 -74
  36. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  37. package/dist/modules/customers/cli.js +28 -2
  38. package/dist/modules/customers/cli.js.map +2 -2
  39. package/dist/modules/customers/commands/deals.js +34 -2
  40. package/dist/modules/customers/commands/deals.js.map +2 -2
  41. package/dist/modules/customers/commands/index.js +2 -0
  42. package/dist/modules/customers/commands/index.js.map +2 -2
  43. package/dist/modules/customers/commands/pipeline-stages.js +126 -0
  44. package/dist/modules/customers/commands/pipeline-stages.js.map +7 -0
  45. package/dist/modules/customers/commands/pipelines.js +87 -0
  46. package/dist/modules/customers/commands/pipelines.js.map +7 -0
  47. package/dist/modules/customers/components/DictionarySettings.js +0 -5
  48. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  49. package/dist/modules/customers/components/PipelineSettings.js +474 -0
  50. package/dist/modules/customers/components/PipelineSettings.js.map +7 -0
  51. package/dist/modules/customers/components/detail/DealForm.js +84 -12
  52. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  53. package/dist/modules/customers/data/entities.js +78 -0
  54. package/dist/modules/customers/data/entities.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +44 -0
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/migrations/Migration20260218191730.js +77 -0
  58. package/dist/modules/customers/migrations/Migration20260218191730.js.map +7 -0
  59. package/dist/modules/customers/setup.js +7 -3
  60. package/dist/modules/customers/setup.js.map +2 -2
  61. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +46 -44
  62. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  63. package/dist/modules/translations/api/context.js +10 -1
  64. package/dist/modules/translations/api/context.js.map +2 -2
  65. package/dist/modules/translations/commands/index.js +2 -0
  66. package/dist/modules/translations/commands/index.js.map +7 -0
  67. package/dist/modules/translations/commands/translations.js +160 -0
  68. package/dist/modules/translations/commands/translations.js.map +7 -0
  69. package/dist/modules/translations/index.js +1 -0
  70. package/dist/modules/translations/index.js.map +2 -2
  71. package/dist/modules/workflows/migrations/Migration20260222205305.js +14 -0
  72. package/dist/modules/workflows/migrations/Migration20260222205305.js.map +7 -0
  73. package/generated/entities/customer_deal/index.ts +2 -0
  74. package/generated/entities/customer_pipeline/index.ts +7 -0
  75. package/generated/entities/customer_pipeline_stage/index.ts +8 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +4 -0
  78. package/package.json +2 -2
  79. package/src/modules/customers/acl.ts +2 -0
  80. package/src/modules/customers/api/deals/[id]/route.ts +4 -0
  81. package/src/modules/customers/api/deals/route.ts +12 -0
  82. package/src/modules/customers/api/dictionaries/[kind]/route.ts +21 -1
  83. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +71 -0
  84. package/src/modules/customers/api/pipeline-stages/route.ts +296 -0
  85. package/src/modules/customers/api/pipelines/route.ts +261 -0
  86. package/src/modules/customers/backend/config/customers/page.tsx +2 -0
  87. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +13 -0
  88. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +512 -0
  89. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
  90. package/src/modules/customers/backend/customers/deals/page.tsx +33 -1
  91. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +119 -79
  92. package/src/modules/customers/cli.ts +29 -1
  93. package/src/modules/customers/commands/deals.ts +44 -1
  94. package/src/modules/customers/commands/index.ts +2 -0
  95. package/src/modules/customers/commands/pipeline-stages.ts +156 -0
  96. package/src/modules/customers/commands/pipelines.ts +105 -0
  97. package/src/modules/customers/components/DictionarySettings.tsx +0 -5
  98. package/src/modules/customers/components/PipelineSettings.tsx +570 -0
  99. package/src/modules/customers/components/detail/DealForm.tsx +89 -11
  100. package/src/modules/customers/data/entities.ts +64 -0
  101. package/src/modules/customers/data/validators.ts +57 -0
  102. package/src/modules/customers/i18n/de.json +4 -0
  103. package/src/modules/customers/i18n/en.json +4 -0
  104. package/src/modules/customers/i18n/es.json +4 -0
  105. package/src/modules/customers/i18n/pl.json +5 -1
  106. package/src/modules/customers/migrations/Migration20260218191730.ts +84 -0
  107. package/src/modules/customers/setup.ts +5 -1
  108. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +65 -60
  109. package/src/modules/translations/api/context.ts +12 -0
  110. package/src/modules/translations/commands/index.ts +1 -0
  111. package/src/modules/translations/commands/translations.ts +253 -0
  112. package/src/modules/translations/index.ts +1 -0
  113. package/src/modules/workflows/migrations/Migration20260222205305.ts +13 -0
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import { resolveTranslationsRouteContext } from '@open-mercato/core/modules/translations/api/context'
4
4
  import { translationBodySchema, entityTypeParamSchema, entityIdParamSchema } from '@open-mercato/core/modules/translations/data/validators'
5
5
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
+ import { CommandBus } from '@open-mercato/shared/lib/commands'
7
+ import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
6
8
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
9
 
8
10
  const paramsSchema = z.object({
@@ -67,64 +69,53 @@ export async function PUT(req: Request, ctx: { params?: { entityType?: string; e
67
69
  const rawBody = await req.json().catch(() => ({}))
68
70
  const translations = translationBodySchema.parse(rawBody)
69
71
 
70
- const existing = await context.knex('entity_translations')
71
- .where({
72
- entity_type: entityType,
73
- entity_id: entityId,
74
- })
75
- .andWhereRaw('tenant_id is not distinct from ?', [context.tenantId])
76
- .andWhereRaw('organization_id is not distinct from ?', [context.organizationId])
77
- .first()
78
-
79
- const now = context.knex.fn.now()
80
-
81
- if (existing) {
82
- await context.knex('entity_translations')
83
- .where({ id: existing.id })
84
- .update({
85
- translations,
86
- updated_at: now,
87
- })
88
- } else {
89
- await context.knex('entity_translations').insert({
90
- entity_type: entityType,
91
- entity_id: entityId,
92
- organization_id: context.organizationId,
93
- tenant_id: context.tenantId,
94
- translations,
95
- created_at: now,
96
- updated_at: now,
97
- })
98
- }
99
-
100
- try {
101
- const bus = context.container.resolve<{ emitEvent: (event: string, payload: unknown) => Promise<void> }>('eventBus')
102
- await bus.emitEvent('translations.translation.updated', {
72
+ const commandBus = context.container.resolve('commandBus') as CommandBus
73
+ const { result, logEntry } = await commandBus.execute<
74
+ { entityType: string; entityId: string; translations: typeof translations; organizationId: string | null; tenantId: string },
75
+ { rowId: string }
76
+ >('translations.translation.save', {
77
+ input: {
103
78
  entityType,
104
79
  entityId,
80
+ translations,
105
81
  organizationId: context.organizationId,
106
82
  tenantId: context.tenantId,
107
- })
108
- } catch (err) {
109
- console.warn('[translations] Failed to emit translations.translation.updated:', err instanceof Error ? err.message : 'unknown')
110
- }
83
+ },
84
+ ctx: context.commandCtx,
85
+ })
111
86
 
112
87
  const row = await context.knex('entity_translations')
113
- .where({
114
- entity_type: entityType,
115
- entity_id: entityId,
116
- })
117
- .andWhereRaw('tenant_id is not distinct from ?', [context.tenantId])
118
- .andWhereRaw('organization_id is not distinct from ?', [context.organizationId])
88
+ .where({ id: result.rowId })
119
89
  .first()
120
90
 
121
- return NextResponse.json({
91
+ const response = NextResponse.json({
122
92
  entityType: row.entity_type,
123
93
  entityId: row.entity_id,
124
94
  translations: row.translations,
125
95
  createdAt: row.created_at,
126
96
  updatedAt: row.updated_at,
127
97
  })
98
+
99
+ if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
100
+ response.headers.set(
101
+ 'x-om-operation',
102
+ serializeOperationMetadata({
103
+ id: logEntry.id,
104
+ undoToken: logEntry.undoToken,
105
+ commandId: logEntry.commandId,
106
+ actionLabel: logEntry.actionLabel ?? null,
107
+ resourceKind: logEntry.resourceKind ?? 'translations.translation',
108
+ resourceId: logEntry.resourceId ?? result.rowId,
109
+ executedAt: logEntry.createdAt instanceof Date
110
+ ? logEntry.createdAt.toISOString()
111
+ : typeof logEntry.createdAt === 'string'
112
+ ? logEntry.createdAt
113
+ : new Date().toISOString(),
114
+ }),
115
+ )
116
+ }
117
+
118
+ return response
128
119
  } catch (err) {
129
120
  if (err instanceof CrudHttpError) {
130
121
  return NextResponse.json(err.body, { status: err.status })
@@ -145,28 +136,42 @@ export async function DELETE(req: Request, ctx: { params?: { entityType?: string
145
136
  entityId: ctx.params?.entityId,
146
137
  })
147
138
 
148
- await context.knex('entity_translations')
149
- .where({
150
- entity_type: entityType,
151
- entity_id: entityId,
152
- })
153
- .andWhereRaw('tenant_id is not distinct from ?', [context.tenantId])
154
- .andWhereRaw('organization_id is not distinct from ?', [context.organizationId])
155
- .del()
156
-
157
- try {
158
- const bus = context.container.resolve<{ emitEvent: (event: string, payload: unknown) => Promise<void> }>('eventBus')
159
- await bus.emitEvent('translations.translation.deleted', {
139
+ const commandBus = context.container.resolve('commandBus') as CommandBus
140
+ const { logEntry } = await commandBus.execute<
141
+ { entityType: string; entityId: string; organizationId: string | null; tenantId: string },
142
+ { deleted: boolean }
143
+ >('translations.translation.delete', {
144
+ input: {
160
145
  entityType,
161
146
  entityId,
162
147
  organizationId: context.organizationId,
163
148
  tenantId: context.tenantId,
164
- })
165
- } catch (err) {
166
- console.warn('[translations] Failed to emit translations.translation.deleted:', err instanceof Error ? err.message : 'unknown')
149
+ },
150
+ ctx: context.commandCtx,
151
+ })
152
+
153
+ const response = new NextResponse(null, { status: 204 })
154
+
155
+ if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
156
+ response.headers.set(
157
+ 'x-om-operation',
158
+ serializeOperationMetadata({
159
+ id: logEntry.id,
160
+ undoToken: logEntry.undoToken,
161
+ commandId: logEntry.commandId,
162
+ actionLabel: logEntry.actionLabel ?? null,
163
+ resourceKind: logEntry.resourceKind ?? 'translations.translation',
164
+ resourceId: logEntry.resourceId ?? null,
165
+ executedAt: logEntry.createdAt instanceof Date
166
+ ? logEntry.createdAt.toISOString()
167
+ : typeof logEntry.createdAt === 'string'
168
+ ? logEntry.createdAt
169
+ : new Date().toISOString(),
170
+ }),
171
+ )
167
172
  }
168
173
 
169
- return new NextResponse(null, { status: 204 })
174
+ return response
170
175
  } catch (err) {
171
176
  if (err instanceof CrudHttpError) {
172
177
  return NextResponse.json(err.body, { status: err.status })
@@ -2,6 +2,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
4
4
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
5
6
  import type { EntityManager } from '@mikro-orm/postgresql'
6
7
  import type { AwilixContainer } from 'awilix'
7
8
  import type { Knex } from 'knex'
@@ -13,6 +14,7 @@ export type TranslationsRouteContext = {
13
14
  knex: Knex
14
15
  organizationId: string | null
15
16
  tenantId: string
17
+ commandCtx: CommandRuntimeContext
16
18
  }
17
19
 
18
20
  export async function resolveTranslationsRouteContext(req: Request): Promise<TranslationsRouteContext> {
@@ -28,6 +30,15 @@ export async function resolveTranslationsRouteContext(req: Request): Promise<Tra
28
30
  const tenantId: string = scope?.tenantId ?? auth.tenantId
29
31
  const organizationId = scope?.selectedId ?? auth.orgId ?? null
30
32
 
33
+ const commandCtx: CommandRuntimeContext = {
34
+ container,
35
+ auth,
36
+ organizationScope: scope,
37
+ selectedOrganizationId: organizationId,
38
+ organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),
39
+ request: req,
40
+ }
41
+
31
42
  return {
32
43
  container,
33
44
  auth,
@@ -35,5 +46,6 @@ export async function resolveTranslationsRouteContext(req: Request): Promise<Tra
35
46
  knex,
36
47
  organizationId,
37
48
  tenantId,
49
+ commandCtx,
38
50
  }
39
51
  }
@@ -0,0 +1 @@
1
+ import './translations'
@@ -0,0 +1,253 @@
1
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
2
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
+ import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
4
+ import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import type { Knex } from 'knex'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import { emitTranslationsEvent } from '../events'
9
+
10
+ type TranslationSnapshot = {
11
+ id: string | null
12
+ entityType: string
13
+ entityId: string
14
+ translations: Record<string, Record<string, string | null>> | null
15
+ organizationId: string | null
16
+ tenantId: string
17
+ }
18
+
19
+ type TranslationUndoPayload = {
20
+ before?: TranslationSnapshot | null
21
+ after?: TranslationSnapshot | null
22
+ }
23
+
24
+ type SaveInput = {
25
+ entityType: string
26
+ entityId: string
27
+ translations: Record<string, Record<string, string | null>>
28
+ organizationId: string | null
29
+ tenantId: string
30
+ }
31
+
32
+ type DeleteInput = {
33
+ entityType: string
34
+ entityId: string
35
+ organizationId: string | null
36
+ tenantId: string
37
+ }
38
+
39
+ function resolveKnex(ctx: CommandRuntimeContext): Knex {
40
+ const em = ctx.container.resolve('em') as EntityManager
41
+ return (em as unknown as { getConnection(): { getKnex(): Knex } }).getConnection().getKnex()
42
+ }
43
+
44
+ async function loadTranslationSnapshot(
45
+ knex: Knex,
46
+ entityType: string,
47
+ entityId: string,
48
+ tenantId: string,
49
+ organizationId: string | null,
50
+ ): Promise<TranslationSnapshot | null> {
51
+ const row = await knex('entity_translations')
52
+ .where({ entity_type: entityType, entity_id: entityId })
53
+ .andWhereRaw('tenant_id is not distinct from ?', [tenantId])
54
+ .andWhereRaw('organization_id is not distinct from ?', [organizationId])
55
+ .first()
56
+
57
+ if (!row) return null
58
+ return {
59
+ id: row.id,
60
+ entityType: row.entity_type,
61
+ entityId: row.entity_id,
62
+ translations: row.translations ?? null,
63
+ organizationId: row.organization_id ?? null,
64
+ tenantId: row.tenant_id,
65
+ }
66
+ }
67
+
68
+ const saveTranslationCommand: CommandHandler<SaveInput, { rowId: string }> = {
69
+ id: 'translations.translation.save',
70
+
71
+ async prepare(input, ctx) {
72
+ ensureTenantScope(ctx, input.tenantId)
73
+ const knex = resolveKnex(ctx)
74
+ const snapshot = await loadTranslationSnapshot(knex, input.entityType, input.entityId, input.tenantId, input.organizationId)
75
+ return { before: snapshot }
76
+ },
77
+
78
+ async execute(input, ctx) {
79
+ const knex = resolveKnex(ctx)
80
+ const existing = await knex('entity_translations')
81
+ .where({ entity_type: input.entityType, entity_id: input.entityId })
82
+ .andWhereRaw('tenant_id is not distinct from ?', [input.tenantId])
83
+ .andWhereRaw('organization_id is not distinct from ?', [input.organizationId])
84
+ .first()
85
+
86
+ const now = knex.fn.now()
87
+
88
+ if (existing) {
89
+ await knex('entity_translations')
90
+ .where({ id: existing.id })
91
+ .update({ translations: input.translations, updated_at: now })
92
+ } else {
93
+ await knex('entity_translations').insert({
94
+ entity_type: input.entityType,
95
+ entity_id: input.entityId,
96
+ organization_id: input.organizationId,
97
+ tenant_id: input.tenantId,
98
+ translations: input.translations,
99
+ created_at: now,
100
+ updated_at: now,
101
+ })
102
+ }
103
+
104
+ await emitTranslationsEvent('translations.translation.updated', {
105
+ entityType: input.entityType,
106
+ entityId: input.entityId,
107
+ organizationId: input.organizationId,
108
+ tenantId: input.tenantId,
109
+ }, { persistent: true }).catch(() => undefined)
110
+
111
+ const saved = await knex('entity_translations')
112
+ .where({ entity_type: input.entityType, entity_id: input.entityId })
113
+ .andWhereRaw('tenant_id is not distinct from ?', [input.tenantId])
114
+ .andWhereRaw('organization_id is not distinct from ?', [input.organizationId])
115
+ .first()
116
+
117
+ return { rowId: saved.id }
118
+ },
119
+
120
+ async captureAfter(input, _result, ctx) {
121
+ const knex = resolveKnex(ctx)
122
+ return await loadTranslationSnapshot(knex, input.entityType, input.entityId, input.tenantId, input.organizationId)
123
+ },
124
+
125
+ async buildLog({ snapshots, result }) {
126
+ const { translate } = await resolveTranslations()
127
+ const before = snapshots.before as TranslationSnapshot | null | undefined
128
+ const after = snapshots.after as TranslationSnapshot | null | undefined
129
+ return {
130
+ actionLabel: translate('translations.audit.save', 'Save translation'),
131
+ resourceKind: 'translations.translation',
132
+ resourceId: result.rowId,
133
+ tenantId: after?.tenantId ?? before?.tenantId ?? null,
134
+ organizationId: after?.organizationId ?? before?.organizationId ?? null,
135
+ snapshotBefore: before ?? null,
136
+ snapshotAfter: after ?? null,
137
+ payload: {
138
+ undo: { before: before ?? null, after: after ?? null } satisfies TranslationUndoPayload,
139
+ },
140
+ }
141
+ },
142
+
143
+ async undo({ logEntry, ctx }) {
144
+ const payload = extractUndoPayload<TranslationUndoPayload>(logEntry)
145
+ const before = payload?.before ?? null
146
+ const knex = resolveKnex(ctx)
147
+
148
+ if (!before || !before.translations) {
149
+ // Was a create — delete the record
150
+ const resourceId = logEntry?.resourceId
151
+ if (resourceId) {
152
+ await knex('entity_translations').where({ id: resourceId }).del()
153
+ }
154
+ } else {
155
+ // Was an update — restore previous translations
156
+ const existing = await knex('entity_translations')
157
+ .where({ entity_type: before.entityType, entity_id: before.entityId })
158
+ .andWhereRaw('tenant_id is not distinct from ?', [before.tenantId])
159
+ .andWhereRaw('organization_id is not distinct from ?', [before.organizationId])
160
+ .first()
161
+
162
+ if (existing) {
163
+ await knex('entity_translations')
164
+ .where({ id: existing.id })
165
+ .update({ translations: before.translations, updated_at: knex.fn.now() })
166
+ } else {
167
+ await knex('entity_translations').insert({
168
+ entity_type: before.entityType,
169
+ entity_id: before.entityId,
170
+ organization_id: before.organizationId,
171
+ tenant_id: before.tenantId,
172
+ translations: before.translations,
173
+ created_at: knex.fn.now(),
174
+ updated_at: knex.fn.now(),
175
+ })
176
+ }
177
+ }
178
+ },
179
+ }
180
+
181
+ const deleteTranslationCommand: CommandHandler<DeleteInput, { deleted: boolean }> = {
182
+ id: 'translations.translation.delete',
183
+
184
+ async prepare(input, ctx) {
185
+ ensureTenantScope(ctx, input.tenantId)
186
+ const knex = resolveKnex(ctx)
187
+ const snapshot = await loadTranslationSnapshot(knex, input.entityType, input.entityId, input.tenantId, input.organizationId)
188
+ return { before: snapshot }
189
+ },
190
+
191
+ async execute(input, ctx) {
192
+ const knex = resolveKnex(ctx)
193
+ const count = await knex('entity_translations')
194
+ .where({ entity_type: input.entityType, entity_id: input.entityId })
195
+ .andWhereRaw('tenant_id is not distinct from ?', [input.tenantId])
196
+ .andWhereRaw('organization_id is not distinct from ?', [input.organizationId])
197
+ .del()
198
+
199
+ await emitTranslationsEvent('translations.translation.deleted', {
200
+ entityType: input.entityType,
201
+ entityId: input.entityId,
202
+ organizationId: input.organizationId,
203
+ tenantId: input.tenantId,
204
+ }, { persistent: true }).catch(() => undefined)
205
+
206
+ return { deleted: count > 0 }
207
+ },
208
+
209
+ async buildLog({ snapshots }) {
210
+ const before = snapshots.before as TranslationSnapshot | null | undefined
211
+ if (!before) return null
212
+ const { translate } = await resolveTranslations()
213
+ return {
214
+ actionLabel: translate('translations.audit.delete', 'Delete translation'),
215
+ resourceKind: 'translations.translation',
216
+ resourceId: before.id ?? undefined,
217
+ tenantId: before.tenantId,
218
+ organizationId: before.organizationId,
219
+ snapshotBefore: before,
220
+ payload: {
221
+ undo: { before } satisfies TranslationUndoPayload,
222
+ },
223
+ }
224
+ },
225
+
226
+ async undo({ logEntry, ctx }) {
227
+ const payload = extractUndoPayload<TranslationUndoPayload>(logEntry)
228
+ const before = payload?.before
229
+ if (!before || !before.translations) return
230
+ const knex = resolveKnex(ctx)
231
+
232
+ const existing = await knex('entity_translations')
233
+ .where({ entity_type: before.entityType, entity_id: before.entityId })
234
+ .andWhereRaw('tenant_id is not distinct from ?', [before.tenantId])
235
+ .andWhereRaw('organization_id is not distinct from ?', [before.organizationId])
236
+ .first()
237
+
238
+ if (!existing) {
239
+ await knex('entity_translations').insert({
240
+ entity_type: before.entityType,
241
+ entity_id: before.entityId,
242
+ organization_id: before.organizationId,
243
+ tenant_id: before.tenantId,
244
+ translations: before.translations,
245
+ created_at: knex.fn.now(),
246
+ updated_at: knex.fn.now(),
247
+ })
248
+ }
249
+ },
250
+ }
251
+
252
+ registerCommand(saveTranslationCommand)
253
+ registerCommand(deleteTranslationCommand)
@@ -1,4 +1,5 @@
1
1
  import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
2
+ import './commands'
2
3
 
3
4
  export const metadata: ModuleInfo = {
4
5
  name: 'translations',
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260222205305 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`create table if not exists "workflow_event_triggers" ("id" uuid not null default gen_random_uuid(), "name" varchar(255) not null, "description" text null, "workflow_definition_id" uuid not null, "event_pattern" varchar(255) not null, "config" jsonb null, "enabled" boolean not null default true, "priority" int not null default 0, "tenant_id" uuid not null, "organization_id" uuid not null, "created_by" varchar(255) null, "updated_by" varchar(255) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, constraint "workflow_event_triggers_pkey" primary key ("id"));`);
7
+ this.addSql(`create index if not exists "workflow_event_triggers_enabled_priority_idx" on "workflow_event_triggers" ("enabled", "priority");`);
8
+ this.addSql(`create index if not exists "workflow_event_triggers_tenant_org_idx" on "workflow_event_triggers" ("tenant_id", "organization_id");`);
9
+ this.addSql(`create index if not exists "workflow_event_triggers_definition_idx" on "workflow_event_triggers" ("workflow_definition_id");`);
10
+ this.addSql(`create index if not exists "workflow_event_triggers_event_pattern_idx" on "workflow_event_triggers" ("event_pattern", "enabled");`);
11
+ }
12
+
13
+ }