@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +8 -0
- package/dist/generated/entities/module_config/index.js +4 -0
- package/dist/generated/entities/module_config/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +1 -1
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductComplianceSection.js +235 -62
- package/dist/modules/catalog/components/products/ProductComplianceSection.js.map +2 -2
- package/dist/modules/configs/components/CachePanel.js +2 -1
- package/dist/modules/configs/components/CachePanel.js.map +2 -2
- package/dist/modules/configs/components/SystemStatusPanel.js +10 -12
- package/dist/modules/configs/components/SystemStatusPanel.js.map +2 -2
- package/dist/modules/configs/data/entities.js +15 -1
- package/dist/modules/configs/data/entities.js.map +2 -2
- package/dist/modules/configs/lib/module-config-service.js +52 -19
- package/dist/modules/configs/lib/module-config-service.js.map +3 -3
- package/dist/modules/configs/migrations/Migration20260617150000.js +21 -0
- package/dist/modules/configs/migrations/Migration20260617150000.js.map +7 -0
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js +47 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js.map +2 -2
- package/generated/entities/module_config/index.ts +2 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/modules/auth/lib/setup-app.ts +1 -1
- package/src/modules/catalog/components/products/ProductComplianceSection.tsx +138 -27
- package/src/modules/configs/components/CachePanel.tsx +2 -3
- package/src/modules/configs/components/SystemStatusPanel.tsx +12 -19
- package/src/modules/configs/data/entities.ts +17 -1
- package/src/modules/configs/lib/module-config-service.ts +83 -24
- package/src/modules/configs/migrations/.snapshot-open-mercato.json +41 -4
- package/src/modules/configs/migrations/.snapshot-openmercato.json +41 -4
- package/src/modules/configs/migrations/Migration20260617150000.ts +21 -0
- package/src/modules/customers/api/activities/route.ts +6 -0
- 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 = '
|
|
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
|
|
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>(
|
|
93
|
-
|
|
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
|
-
|
|
113
|
-
if (
|
|
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(
|
|
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>(
|
|
123
|
-
|
|
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 (
|
|
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
|
-
|
|
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": "
|
|
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":
|
|
114
|
+
"constraint": false,
|
|
78
115
|
"primary": false,
|
|
79
|
-
"unique":
|
|
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": "
|
|
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":
|
|
114
|
+
"constraint": false,
|
|
78
115
|
"primary": false,
|
|
79
|
-
"unique":
|
|
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
|
],
|