@open-mercato/core 0.6.6-develop.6290.1.4bb5a8ba3f → 0.6.6-develop.6299.1.29224e2d86

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 (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +8 -0
  3. package/dist/generated/entities/module_config/index.js +4 -0
  4. package/dist/generated/entities/module_config/index.js.map +2 -2
  5. package/dist/generated/entity-fields-registry.js +2 -0
  6. package/dist/generated/entity-fields-registry.js.map +2 -2
  7. package/dist/modules/auth/lib/setup-app.js +1 -1
  8. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  9. package/dist/modules/catalog/components/products/ProductComplianceSection.js +235 -62
  10. package/dist/modules/catalog/components/products/ProductComplianceSection.js.map +2 -2
  11. package/dist/modules/configs/components/CachePanel.js +2 -1
  12. package/dist/modules/configs/components/CachePanel.js.map +2 -2
  13. package/dist/modules/configs/components/SystemStatusPanel.js +10 -12
  14. package/dist/modules/configs/components/SystemStatusPanel.js.map +2 -2
  15. package/dist/modules/configs/data/entities.js +15 -1
  16. package/dist/modules/configs/data/entities.js.map +2 -2
  17. package/dist/modules/configs/lib/module-config-service.js +52 -19
  18. package/dist/modules/configs/lib/module-config-service.js.map +3 -3
  19. package/dist/modules/configs/migrations/Migration20260617150000.js +21 -0
  20. package/dist/modules/configs/migrations/Migration20260617150000.js.map +7 -0
  21. package/dist/modules/customers/api/activities/route.js.map +2 -2
  22. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js +47 -1
  23. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js.map +2 -2
  24. package/generated/entities/module_config/index.ts +2 -0
  25. package/generated/entity-fields-registry.ts +2 -0
  26. package/package.json +7 -7
  27. package/src/modules/auth/lib/setup-app.ts +1 -1
  28. package/src/modules/catalog/components/products/ProductComplianceSection.tsx +138 -27
  29. package/src/modules/configs/components/CachePanel.tsx +2 -3
  30. package/src/modules/configs/components/SystemStatusPanel.tsx +12 -19
  31. package/src/modules/configs/data/entities.ts +17 -1
  32. package/src/modules/configs/lib/module-config-service.ts +83 -24
  33. package/src/modules/configs/migrations/.snapshot-open-mercato.json +41 -4
  34. package/src/modules/configs/migrations/.snapshot-openmercato.json +41 -4
  35. package/src/modules/configs/migrations/Migration20260617150000.ts +21 -0
  36. package/src/modules/customers/api/activities/route.ts +6 -0
  37. package/src/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.ts +54 -1
@@ -4,7 +4,7 @@ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { ModuleConfig } from '../data/entities'
5
5
  import { moduleConfigKeySchema } from '../data/validators'
6
6
 
7
- const CACHE_VERSION = 'v1'
7
+ const CACHE_VERSION = 'v2'
8
8
  const CACHE_TTL_MS = 60_000
9
9
 
10
10
  type CachePayload = {
@@ -12,7 +12,10 @@ type CachePayload = {
12
12
  record?: ModuleConfigRecord | null
13
13
  }
14
14
 
15
- const cacheKey = (moduleId: string, name: string) => `module-config:${CACHE_VERSION}:${moduleId}:${name}`
15
+ const scopeKeyPart = (tenantId?: string | null) => (tenantId ?? 'global')
16
+
17
+ const cacheKey = (moduleId: string, name: string, tenantId?: string | null) =>
18
+ `module-config:${CACHE_VERSION}:${moduleId}:${name}:${scopeKeyPart(tenantId)}`
16
19
  const moduleTag = (moduleId: string) => `module-config:module:${moduleId}`
17
20
 
18
21
  const resolveEm = (container: AppContainer): EntityManager | null => {
@@ -31,10 +34,13 @@ const resolveCache = (container: AppContainer): CacheStrategy | null => {
31
34
  }
32
35
  }
33
36
 
34
- const toRecord = (entity: ModuleConfig): ModuleConfigRecord => ({
37
+ const toRecord = (entity: ModuleConfig, source: ModuleConfigSource): ModuleConfigRecord => ({
35
38
  moduleId: entity.moduleId,
36
39
  name: entity.name,
37
40
  value: entity.valueJson ?? null,
41
+ tenantId: entity.tenantId ?? null,
42
+ organizationId: entity.organizationId ?? null,
43
+ source,
38
44
  createdAt: entity.createdAt.toISOString(),
39
45
  updatedAt: entity.updatedAt.toISOString(),
40
46
  })
@@ -73,10 +79,20 @@ const deleteCacheByModule = async (cache: CacheStrategy | null, moduleId: string
73
79
 
74
80
  const normalizeKey = (moduleId: string, name: string) => moduleConfigKeySchema.parse({ moduleId, name })
75
81
 
82
+ export type ModuleConfigSource = 'tenant' | 'instance'
83
+
84
+ export type ConfigScope = {
85
+ tenantId?: string | null
86
+ organizationId?: string | null
87
+ }
88
+
76
89
  export type ModuleConfigRecord = {
77
90
  moduleId: string
78
91
  name: string
79
92
  value: unknown
93
+ tenantId: string | null
94
+ organizationId: string | null
95
+ source: ModuleConfigSource
80
96
  createdAt: string
81
97
  updatedAt: string
82
98
  }
@@ -88,18 +104,23 @@ export type ModuleConfigDefault = {
88
104
  }
89
105
 
90
106
  export type ModuleConfigService = {
91
- getRecord(moduleId: string, name: string): Promise<ModuleConfigRecord | null>
92
- getValue<T = unknown>(moduleId: string, name: string, options?: { defaultValue?: T | null }): Promise<T | null>
93
- setValue(moduleId: string, name: string, value: unknown): Promise<ModuleConfigRecord | null>
107
+ getRecord(moduleId: string, name: string, scope?: ConfigScope): Promise<ModuleConfigRecord | null>
108
+ getValue<T = unknown>(
109
+ moduleId: string,
110
+ name: string,
111
+ options?: { defaultValue?: T | null; scope?: ConfigScope },
112
+ ): Promise<T | null>
113
+ setValue(moduleId: string, name: string, value: unknown, scope?: ConfigScope): Promise<ModuleConfigRecord | null>
94
114
  restoreDefaults(defaults: ModuleConfigDefault[], options?: { force?: boolean }): Promise<void>
95
- invalidate(moduleId: string, name?: string): Promise<void>
115
+ invalidate(moduleId: string, name?: string, scope?: ConfigScope): Promise<void>
96
116
  }
97
117
 
98
118
  export function createModuleConfigService(container: AppContainer): ModuleConfigService {
99
- const getRecord = async (rawModuleId: string, rawName: string): Promise<ModuleConfigRecord | null> => {
119
+ const getRecord = async (rawModuleId: string, rawName: string, scope?: ConfigScope): Promise<ModuleConfigRecord | null> => {
100
120
  const { moduleId, name } = normalizeKey(rawModuleId, rawName)
121
+ const tenantId = scope?.tenantId ?? null
101
122
  const cache = resolveCache(container)
102
- const key = cacheKey(moduleId, name)
123
+ const key = cacheKey(moduleId, name, tenantId)
103
124
  const cached = await readCache(cache, key)
104
125
  if (cached) {
105
126
  if (!cached.found) return null
@@ -109,47 +130,84 @@ export function createModuleConfigService(container: AppContainer): ModuleConfig
109
130
  const em = resolveEm(container)
110
131
  if (!em) return null
111
132
  const repo = em.getRepository(ModuleConfig)
112
- const entity = await repo.findOne({ moduleId, name })
113
- if (!entity) {
133
+
134
+ if (tenantId) {
135
+ const scoped = await repo.findOne({ moduleId, name, tenantId })
136
+ if (scoped) {
137
+ const record = toRecord(scoped, 'tenant')
138
+ await writeCache(cache, key, { found: true, record }, moduleId)
139
+ return record
140
+ }
141
+ const global = await repo.findOne({ moduleId, name, tenantId: null })
142
+ if (!global) {
143
+ await writeCache(cache, key, { found: false }, moduleId)
144
+ return null
145
+ }
146
+ const record = toRecord(global, 'instance')
147
+ await writeCache(cache, key, { found: true, record }, moduleId)
148
+ return record
149
+ }
150
+
151
+ const global = await repo.findOne({ moduleId, name, tenantId: null })
152
+ if (!global) {
114
153
  await writeCache(cache, key, { found: false }, moduleId)
115
154
  return null
116
155
  }
117
- const record = toRecord(entity)
156
+ const record = toRecord(global, 'instance')
118
157
  await writeCache(cache, key, { found: true, record }, moduleId)
119
158
  return record
120
159
  }
121
160
 
122
- const getValue = async <T>(rawModuleId: string, rawName: string, options?: { defaultValue?: T | null }): Promise<T | null> => {
123
- const record = await getRecord(rawModuleId, rawName)
161
+ const getValue = async <T>(
162
+ rawModuleId: string,
163
+ rawName: string,
164
+ options?: { defaultValue?: T | null; scope?: ConfigScope },
165
+ ): Promise<T | null> => {
166
+ const record = await getRecord(rawModuleId, rawName, options?.scope)
124
167
  if (!record) return options?.defaultValue ?? null
125
168
  const value = record.value as T | null | undefined
126
169
  if (value === undefined || value === null) return options?.defaultValue ?? null
127
170
  return value
128
171
  }
129
172
 
130
- const setValue = async (rawModuleId: string, rawName: string, value: unknown): Promise<ModuleConfigRecord | null> => {
173
+ const setValue = async (
174
+ rawModuleId: string,
175
+ rawName: string,
176
+ value: unknown,
177
+ scope?: ConfigScope,
178
+ ): Promise<ModuleConfigRecord | null> => {
131
179
  const em = resolveEm(container)
132
180
  if (!em) return null
133
181
  const { moduleId, name } = normalizeKey(rawModuleId, rawName)
182
+ const tenantId = scope?.tenantId ?? null
183
+ const organizationId = scope?.organizationId ?? null
134
184
  const repo = em.getRepository(ModuleConfig)
135
185
  const now = new Date()
136
- let entity = await repo.findOne({ moduleId, name })
186
+ let entity = await repo.findOne({ moduleId, name, tenantId })
137
187
  if (!entity) {
138
188
  entity = repo.create({
139
189
  moduleId,
140
190
  name,
141
191
  valueJson: value ?? null,
192
+ tenantId,
193
+ organizationId,
142
194
  createdAt: now,
143
195
  updatedAt: now,
144
196
  })
145
197
  em.persist(entity)
146
198
  } else {
147
199
  entity.valueJson = value ?? null
200
+ entity.organizationId = organizationId
148
201
  entity.updatedAt = now
149
202
  }
150
203
  await em.flush()
151
- const record = toRecord(entity)
152
- await writeCache(resolveCache(container), cacheKey(moduleId, name), { found: true, record }, moduleId)
204
+ const record = toRecord(entity, tenantId ? 'tenant' : 'instance')
205
+ const cache = resolveCache(container)
206
+ if (tenantId) {
207
+ await writeCache(cache, cacheKey(moduleId, name, tenantId), { found: true, record }, moduleId)
208
+ } else {
209
+ await deleteCacheByModule(cache, moduleId)
210
+ }
153
211
  return record
154
212
  }
155
213
 
@@ -165,12 +223,14 @@ export function createModuleConfigService(container: AppContainer): ModuleConfig
165
223
  let entity: ModuleConfig | null = null
166
224
  try {
167
225
  const { moduleId, name } = normalizeKey(entry.moduleId, entry.name)
168
- entity = await repo.findOne({ moduleId, name })
226
+ entity = await repo.findOne({ moduleId, name, tenantId: null })
169
227
  if (!entity) {
170
228
  entity = repo.create({
171
229
  moduleId,
172
230
  name,
173
231
  valueJson: entry.value ?? null,
232
+ tenantId: null,
233
+ organizationId: null,
174
234
  })
175
235
  em.persist(entity)
176
236
  dirty = true
@@ -186,17 +246,17 @@ export function createModuleConfigService(container: AppContainer): ModuleConfig
186
246
  if (!dirty) return
187
247
  await em.flush()
188
248
  for (const entity of touched) {
189
- const record = toRecord(entity)
190
- await writeCache(cache, cacheKey(entity.moduleId, entity.name), { found: true, record }, entity.moduleId)
249
+ const record = toRecord(entity, 'instance')
250
+ await writeCache(cache, cacheKey(entity.moduleId, entity.name, null), { found: true, record }, entity.moduleId)
191
251
  }
192
252
  }
193
253
 
194
- const invalidate = async (rawModuleId: string, rawName?: string) => {
254
+ const invalidate = async (rawModuleId: string, rawName?: string, scope?: ConfigScope) => {
195
255
  const cache = resolveCache(container)
196
256
  if (!cache) return
197
257
  if (rawName) {
198
258
  const { moduleId, name } = normalizeKey(rawModuleId, rawName)
199
- await deleteCacheKey(cache, cacheKey(moduleId, name))
259
+ await deleteCacheKey(cache, cacheKey(moduleId, name, scope?.tenantId ?? null))
200
260
  return
201
261
  }
202
262
  const moduleId = moduleConfigKeySchema.shape.moduleId.parse(rawModuleId)
@@ -211,4 +271,3 @@ export function createModuleConfigService(container: AppContainer): ModuleConfig
211
271
  invalidate,
212
272
  }
213
273
  }
214
-
@@ -43,6 +43,24 @@
43
43
  "nullable": true,
44
44
  "mappedType": "json"
45
45
  },
46
+ "organization_id": {
47
+ "name": "organization_id",
48
+ "type": "uuid",
49
+ "unsigned": false,
50
+ "autoincrement": false,
51
+ "primary": false,
52
+ "nullable": true,
53
+ "mappedType": "uuid"
54
+ },
55
+ "tenant_id": {
56
+ "name": "tenant_id",
57
+ "type": "uuid",
58
+ "unsigned": false,
59
+ "autoincrement": false,
60
+ "primary": false,
61
+ "nullable": true,
62
+ "mappedType": "uuid"
63
+ },
46
64
  "created_at": {
47
65
  "name": "created_at",
48
66
  "type": "timestamptz",
@@ -68,15 +86,34 @@
68
86
  "schema": "public",
69
87
  "indexes": [
70
88
  {
71
- "keyName": "module_configs_module_name_unique",
89
+ "keyName": "module_configs_global_unique",
90
+ "columnNames": [],
91
+ "composite": false,
92
+ "constraint": false,
93
+ "primary": false,
94
+ "unique": false,
95
+ "expression": "create unique index \"module_configs_global_unique\" on \"module_configs\" (\"module_id\", \"name\") where \"tenant_id\" is null"
96
+ },
97
+ {
98
+ "keyName": "module_configs_scoped_unique",
99
+ "columnNames": [],
100
+ "composite": false,
101
+ "constraint": false,
102
+ "primary": false,
103
+ "unique": false,
104
+ "expression": "create unique index \"module_configs_scoped_unique\" on \"module_configs\" (\"module_id\", \"name\", \"tenant_id\") where \"tenant_id\" is not null"
105
+ },
106
+ {
107
+ "keyName": "module_configs_module_name_tenant_idx",
72
108
  "columnNames": [
73
109
  "module_id",
74
- "name"
110
+ "name",
111
+ "tenant_id"
75
112
  ],
76
113
  "composite": true,
77
- "constraint": true,
114
+ "constraint": false,
78
115
  "primary": false,
79
- "unique": true
116
+ "unique": false
80
117
  },
81
118
  {
82
119
  "keyName": "module_configs_pkey",
@@ -43,6 +43,24 @@
43
43
  "nullable": true,
44
44
  "mappedType": "json"
45
45
  },
46
+ "organization_id": {
47
+ "name": "organization_id",
48
+ "type": "uuid",
49
+ "unsigned": false,
50
+ "autoincrement": false,
51
+ "primary": false,
52
+ "nullable": true,
53
+ "mappedType": "uuid"
54
+ },
55
+ "tenant_id": {
56
+ "name": "tenant_id",
57
+ "type": "uuid",
58
+ "unsigned": false,
59
+ "autoincrement": false,
60
+ "primary": false,
61
+ "nullable": true,
62
+ "mappedType": "uuid"
63
+ },
46
64
  "created_at": {
47
65
  "name": "created_at",
48
66
  "type": "timestamptz",
@@ -68,15 +86,34 @@
68
86
  "schema": "public",
69
87
  "indexes": [
70
88
  {
71
- "keyName": "module_configs_module_name_unique",
89
+ "keyName": "module_configs_global_unique",
90
+ "columnNames": [],
91
+ "composite": false,
92
+ "constraint": false,
93
+ "primary": false,
94
+ "unique": false,
95
+ "expression": "create unique index \"module_configs_global_unique\" on \"module_configs\" (\"module_id\", \"name\") where \"tenant_id\" is null"
96
+ },
97
+ {
98
+ "keyName": "module_configs_scoped_unique",
99
+ "columnNames": [],
100
+ "composite": false,
101
+ "constraint": false,
102
+ "primary": false,
103
+ "unique": false,
104
+ "expression": "create unique index \"module_configs_scoped_unique\" on \"module_configs\" (\"module_id\", \"name\", \"tenant_id\") where \"tenant_id\" is not null"
105
+ },
106
+ {
107
+ "keyName": "module_configs_module_name_tenant_idx",
72
108
  "columnNames": [
73
109
  "module_id",
74
- "name"
110
+ "name",
111
+ "tenant_id"
75
112
  ],
76
113
  "composite": true,
77
- "constraint": true,
114
+ "constraint": false,
78
115
  "primary": false,
79
- "unique": true
116
+ "unique": false
80
117
  },
81
118
  {
82
119
  "keyName": "module_configs_pkey",
@@ -0,0 +1,21 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260617150000 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "module_configs" add column "organization_id" uuid null, add column "tenant_id" uuid null;`);
7
+ this.addSql(`alter table "module_configs" drop constraint "module_configs_module_name_unique";`);
8
+ this.addSql(`create unique index "module_configs_global_unique" on "module_configs" ("module_id", "name") where "tenant_id" is null;`);
9
+ this.addSql(`create unique index "module_configs_scoped_unique" on "module_configs" ("module_id", "name", "tenant_id") where "tenant_id" is not null;`);
10
+ this.addSql(`create index "module_configs_module_name_tenant_idx" on "module_configs" ("module_id", "name", "tenant_id");`);
11
+ }
12
+
13
+ override async down(): Promise<void> {
14
+ this.addSql(`drop index if exists "module_configs_module_name_tenant_idx";`);
15
+ this.addSql(`drop index if exists "module_configs_scoped_unique";`);
16
+ this.addSql(`drop index if exists "module_configs_global_unique";`);
17
+ this.addSql(`alter table "module_configs" add constraint "module_configs_module_name_unique" unique ("module_id", "name");`);
18
+ this.addSql(`alter table "module_configs" drop column "organization_id", drop column "tenant_id";`);
19
+ }
20
+
21
+ }
@@ -74,6 +74,12 @@ const ADAPTER_HEADERS = {
74
74
  // canonical bridge) read path. Keeps memory bounded on tenants with large
75
75
  // activity history; deep-pagination beyond this window is not supported here —
76
76
  // use /api/customers/interactions instead.
77
+ //
78
+ // Audited for the #3386 rollout (P2): this merged path sorts only on
79
+ // non-encrypted system timestamps (createdAt/occurredAt, see
80
+ // resolveActivitySortValue), so the #3278 two-phase *encrypted*-sort migration
81
+ // is intentionally not applied here. Merge-order correctness across the page
82
+ // boundary is covered by __tests__/merged-pagination.test.ts.
77
83
  const MERGED_ACTIVITY_FETCH_CAP = 2000
78
84
 
79
85
  type ActivityItem = {
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { raw } from '@mikro-orm/core'
2
3
  import { Resend } from 'resend'
3
4
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
5
  import { resolveDefaultEmailFromAddress } from '@open-mercato/shared/lib/email/config'
@@ -111,6 +112,57 @@ export async function POST(req: Request) {
111
112
  headers['References'] = references.join(' ')
112
113
  }
113
114
 
115
+ // Atomically claim the send right before calling the external email provider so
116
+ // two concurrent requests cannot both deliver the same reply. The claim only
117
+ // succeeds when neither replySentAt nor replySendClaimedAt is already set in the
118
+ // metadata; a losing request gets 0 rows back and is rejected with 409.
119
+ const sendClaimedAt = new Date().toISOString()
120
+ const claimed = await ctx.em.nativeUpdate(
121
+ InboxProposalAction,
122
+ {
123
+ id: action.id,
124
+ proposalId: proposal.id,
125
+ actionType: 'draft_reply',
126
+ organizationId: ctx.organizationId,
127
+ tenantId: ctx.tenantId,
128
+ deletedAt: null,
129
+ status: 'executed',
130
+ [raw(`coalesce("metadata"->>'replySentAt', '')`)]: '',
131
+ [raw(`coalesce("metadata"->>'replySendClaimedAt', '')`)]: '',
132
+ },
133
+ {
134
+ metadata: raw(
135
+ `coalesce("metadata", '{}'::jsonb) || jsonb_build_object('replySendClaimedAt', ?::text)`,
136
+ [sendClaimedAt],
137
+ ),
138
+ },
139
+ )
140
+
141
+ if (claimed === 0) {
142
+ return NextResponse.json(
143
+ { error: 'Reply send is already in progress or has been sent for this action' },
144
+ { status: 409 },
145
+ )
146
+ }
147
+
148
+ const releaseSendClaim = async () => {
149
+ try {
150
+ await ctx.em.nativeUpdate(
151
+ InboxProposalAction,
152
+ {
153
+ id: action.id,
154
+ organizationId: ctx.organizationId,
155
+ tenantId: ctx.tenantId,
156
+ [raw(`coalesce("metadata"->>'replySentAt', '')`)]: '',
157
+ [raw(`("metadata"->>'replySendClaimedAt')`)]: sendClaimedAt,
158
+ },
159
+ { metadata: raw(`"metadata" - 'replySendClaimedAt'`) },
160
+ )
161
+ } catch (releaseError) {
162
+ console.error('[inbox_ops:reply:send] Failed to release send claim:', releaseError)
163
+ }
164
+ }
165
+
114
166
  const resend = new Resend(apiKey)
115
167
  const { data: sendData, error: sendError } = await resend.emails.send({
116
168
  to: toAddress,
@@ -121,6 +173,7 @@ export async function POST(req: Request) {
121
173
  })
122
174
 
123
175
  if (sendError) {
176
+ await releaseSendClaim()
124
177
  const errorMessage = sendError.message || 'Unknown error'
125
178
  return NextResponse.json({ error: `Failed to send email: ${errorMessage}` }, { status: 502 })
126
179
  }
@@ -182,7 +235,7 @@ export const openApi: OpenApiRouteDoc = {
182
235
  { status: 200, description: 'Reply sent successfully' },
183
236
  { status: 400, description: 'Missing required payload fields' },
184
237
  { status: 404, description: 'Reply action not found' },
185
- { status: 409, description: 'Action in invalid state for sending' },
238
+ { status: 409, description: 'Action in invalid state for sending, or a send is already in progress / completed for this reply' },
186
239
  { status: 502, description: 'Email delivery failed' },
187
240
  { status: 503, description: 'Email service not configured or disabled' },
188
241
  ],