@open-mercato/core 0.6.7-develop.6744.1.0483b18565 → 0.6.7-develop.6749.1.6b54c56dfe
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/dist/modules/auth/api/profile/route.js +8 -2
- package/dist/modules/auth/api/profile/route.js.map +2 -2
- package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
- package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
- package/dist/modules/data_sync/lib/queue-policy.js +15 -0
- package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
- package/dist/modules/data_sync/lib/queue.js +16 -1
- package/dist/modules/data_sync/lib/queue.js.map +2 -2
- package/dist/modules/data_sync/lib/start-run.js +2 -1
- package/dist/modules/data_sync/lib/start-run.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +35 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-export.js +9 -2
- package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
- package/dist/modules/data_sync/workers/sync-import.js +9 -2
- package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
- package/dist/modules/notifications/api/route.js +3 -1
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/api/unread-count/route.js +25 -6
- package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
- package/dist/modules/notifications/lib/notificationScope.js +26 -0
- package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
- package/dist/modules/notifications/lib/notificationService.js +7 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +16 -2
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/profile/route.ts +8 -2
- package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
- package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
- package/src/modules/data_sync/lib/adapter.ts +18 -0
- package/src/modules/data_sync/lib/queue-policy.ts +30 -0
- package/src/modules/data_sync/lib/queue.ts +19 -1
- package/src/modules/data_sync/lib/start-run.ts +2 -1
- package/src/modules/data_sync/lib/sync-engine.ts +46 -0
- package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
- package/src/modules/data_sync/workers/sync-export.ts +8 -1
- package/src/modules/data_sync/workers/sync-import.ts +8 -1
- package/src/modules/notifications/api/route.ts +2 -0
- package/src/modules/notifications/api/unread-count/route.ts +42 -5
- package/src/modules/notifications/lib/notificationScope.ts +40 -0
- package/src/modules/notifications/lib/notificationService.ts +5 -0
- package/src/modules/notifications/lib/routeHelpers.ts +19 -2
|
@@ -9,6 +9,7 @@ import { emitDataSyncEvent } from '../events'
|
|
|
9
9
|
import type { DataSyncAdapter, DataMapping, ExportBatch, ImportBatch } from './adapter'
|
|
10
10
|
import { getDataSyncAdapter } from './adapter-registry'
|
|
11
11
|
import type { SyncRunService } from './sync-run-service'
|
|
12
|
+
import { SyncRunOwnershipConflictError } from './sync-run-service'
|
|
12
13
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
13
14
|
|
|
14
15
|
const logger = createLogger('data_sync').child({ component: 'sync-engine' })
|
|
@@ -236,6 +237,21 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
236
237
|
return
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
if (run.status !== status) {
|
|
241
|
+
// `markStatus` refuses a terminal -> different-terminal transition and
|
|
242
|
+
// returns the row unchanged, so the run is already finished under another
|
|
243
|
+
// delivery of this job. Everything below — the progress job, the
|
|
244
|
+
// operational log and the lifecycle event — would describe the wrong
|
|
245
|
+
// outcome, and `data_sync.run.failed` is dispatched to tenant webhooks.
|
|
246
|
+
// A displaced worker stays silent instead.
|
|
247
|
+
logger.warn('Skipping finalization of a sync run another worker already finalized', {
|
|
248
|
+
runId,
|
|
249
|
+
requestedStatus: status,
|
|
250
|
+
actualStatus: run.status,
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
239
255
|
if (run.progressJobId) {
|
|
240
256
|
if (status === 'completed') {
|
|
241
257
|
await progressService.completeJob(
|
|
@@ -408,6 +424,10 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
408
424
|
throw new Error(`Integration ${run.integrationId} is missing credentials`)
|
|
409
425
|
}
|
|
410
426
|
|
|
427
|
+
// A run already `running` means a stalled job was redelivered, so this is
|
|
428
|
+
// a resume rather than a first start. Consumers see one `started` event per
|
|
429
|
+
// delivery either way; the flag is what lets them tell the two apart.
|
|
430
|
+
const resumed = run.status === 'running'
|
|
411
431
|
const activeRun = await syncRunService.markStatus(run.id, 'running', scope)
|
|
412
432
|
if (!activeRun || activeRun.status !== 'running') {
|
|
413
433
|
return
|
|
@@ -417,6 +437,7 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
417
437
|
integrationId: run.integrationId,
|
|
418
438
|
entityType: run.entityType,
|
|
419
439
|
direction: run.direction,
|
|
440
|
+
resumed,
|
|
420
441
|
tenantId: scope.tenantId,
|
|
421
442
|
organizationId: scope.organizationId,
|
|
422
443
|
})
|
|
@@ -452,6 +473,7 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
452
473
|
const mapping = await resolveMapping(adapter, run.entityType, scope)
|
|
453
474
|
let processedCount = 0
|
|
454
475
|
let totalCount: number | null = null
|
|
476
|
+
let committedBatches = activeRun.batchesCompleted ?? 0
|
|
455
477
|
|
|
456
478
|
try {
|
|
457
479
|
for await (const batch of adapter.streamImport({
|
|
@@ -481,7 +503,9 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
481
503
|
},
|
|
482
504
|
batch.cursor,
|
|
483
505
|
scope,
|
|
506
|
+
committedBatches,
|
|
484
507
|
)
|
|
508
|
+
committedBatches += 1
|
|
485
509
|
|
|
486
510
|
await updateProgress(run.progressJobId, processedCount, totalCount, scope)
|
|
487
511
|
await refreshCoverageSnapshots(batch.refreshCoverageEntityTypes, scope)
|
|
@@ -507,6 +531,13 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
507
531
|
})
|
|
508
532
|
}
|
|
509
533
|
} catch (error) {
|
|
534
|
+
if (error instanceof SyncRunOwnershipConflictError) {
|
|
535
|
+
logger.warn('Yielding import run to a concurrent worker that already advanced it', {
|
|
536
|
+
runId: run.id,
|
|
537
|
+
expectedBatchesCompleted: error.expectedBatchesCompleted,
|
|
538
|
+
})
|
|
539
|
+
return
|
|
540
|
+
}
|
|
510
541
|
const message = error instanceof Error ? error.message : 'Sync import failed'
|
|
511
542
|
await integrationLogService.write(
|
|
512
543
|
{
|
|
@@ -553,6 +584,10 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
553
584
|
throw new Error(`Integration ${run.integrationId} is missing credentials`)
|
|
554
585
|
}
|
|
555
586
|
|
|
587
|
+
// A run already `running` means a stalled job was redelivered, so this is
|
|
588
|
+
// a resume rather than a first start. Consumers see one `started` event per
|
|
589
|
+
// delivery either way; the flag is what lets them tell the two apart.
|
|
590
|
+
const resumed = run.status === 'running'
|
|
556
591
|
const activeRun = await syncRunService.markStatus(run.id, 'running', scope)
|
|
557
592
|
if (!activeRun || activeRun.status !== 'running') {
|
|
558
593
|
return
|
|
@@ -562,6 +597,7 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
562
597
|
integrationId: run.integrationId,
|
|
563
598
|
entityType: run.entityType,
|
|
564
599
|
direction: run.direction,
|
|
600
|
+
resumed,
|
|
565
601
|
tenantId: scope.tenantId,
|
|
566
602
|
organizationId: scope.organizationId,
|
|
567
603
|
})
|
|
@@ -596,6 +632,7 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
596
632
|
|
|
597
633
|
const mapping = await resolveMapping(adapter, run.entityType, scope)
|
|
598
634
|
let processedCount = 0
|
|
635
|
+
let committedBatches = activeRun.batchesCompleted ?? 0
|
|
599
636
|
|
|
600
637
|
try {
|
|
601
638
|
for await (const batch of adapter.streamExport({
|
|
@@ -626,7 +663,9 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
626
663
|
},
|
|
627
664
|
batch.cursor,
|
|
628
665
|
scope,
|
|
666
|
+
committedBatches,
|
|
629
667
|
)
|
|
668
|
+
committedBatches += 1
|
|
630
669
|
await updateProgress(run.progressJobId, processedCount, null, scope)
|
|
631
670
|
await logExportItemFailures(run.id, run.integrationId, batch.results, scope)
|
|
632
671
|
|
|
@@ -647,6 +686,13 @@ export function createSyncEngine(deps: EngineDeps) {
|
|
|
647
686
|
})
|
|
648
687
|
}
|
|
649
688
|
} catch (error) {
|
|
689
|
+
if (error instanceof SyncRunOwnershipConflictError) {
|
|
690
|
+
logger.warn('Yielding export run to a concurrent worker that already advanced it', {
|
|
691
|
+
runId: run.id,
|
|
692
|
+
expectedBatchesCompleted: error.expectedBatchesCompleted,
|
|
693
|
+
})
|
|
694
|
+
return
|
|
695
|
+
}
|
|
650
696
|
const message = error instanceof Error ? error.message : 'Sync export failed'
|
|
651
697
|
await integrationLogService.write(
|
|
652
698
|
{
|
|
@@ -26,6 +26,27 @@ type SyncScope = {
|
|
|
26
26
|
tenantId: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Raised when a batch commit loses the ownership compare-and-swap, meaning
|
|
31
|
+
* another delivery of the same job advanced the run while this worker was
|
|
32
|
+
* streaming.
|
|
33
|
+
*
|
|
34
|
+
* BullMQ guarantees at-least-once delivery: a job whose lock is not renewed is
|
|
35
|
+
* redelivered under the SAME job id, whether the previous worker died or is only
|
|
36
|
+
* blocked. No identity token can tell those apart, so ownership is enforced here
|
|
37
|
+
* — on the write that matters — instead of at claim time. The loser aborts and
|
|
38
|
+
* leaves the run to the worker that is still making progress.
|
|
39
|
+
*/
|
|
40
|
+
export class SyncRunOwnershipConflictError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly runId: string,
|
|
43
|
+
readonly expectedBatchesCompleted: number,
|
|
44
|
+
) {
|
|
45
|
+
super(`[internal] Sync run ${runId} advanced past batch ${expectedBatchesCompleted} under a concurrent worker`)
|
|
46
|
+
this.name = 'SyncRunOwnershipConflictError'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
export function createSyncRunService(em: EntityManager) {
|
|
30
51
|
async function resolveCursorRow(run: SyncRun, scope: SyncScope): Promise<SyncCursor | null> {
|
|
31
52
|
return findOneWithDecryption(
|
|
@@ -150,7 +171,11 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
150
171
|
organizationId: scope.organizationId,
|
|
151
172
|
tenantId: scope.tenantId,
|
|
152
173
|
deletedAt: null,
|
|
153
|
-
|
|
174
|
+
// A BullMQ stalled-job redelivery finds the run in `running` after
|
|
175
|
+
// the previous worker was hard-killed. Treat that transition as an
|
|
176
|
+
// idempotent claim while still excluding terminal states so a
|
|
177
|
+
// cancelled or completed run cannot be revived.
|
|
178
|
+
status: { $in: ['pending', 'running'] },
|
|
154
179
|
},
|
|
155
180
|
{
|
|
156
181
|
status,
|
|
@@ -178,6 +203,12 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
178
203
|
return row
|
|
179
204
|
},
|
|
180
205
|
|
|
206
|
+
/**
|
|
207
|
+
* @deprecated Use {@link commitBatchProgress}, which writes counters and
|
|
208
|
+
* cursor in one transaction behind the ownership fence. This method updates
|
|
209
|
+
* counters unfenced, so two deliveries of the same job can lose each other's
|
|
210
|
+
* increments. Kept for external callers only.
|
|
211
|
+
*/
|
|
181
212
|
async updateCounts(
|
|
182
213
|
runId: string,
|
|
183
214
|
delta: Partial<Pick<SyncRun, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount' | 'batchesCompleted'>>,
|
|
@@ -195,6 +226,11 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
195
226
|
return row
|
|
196
227
|
},
|
|
197
228
|
|
|
229
|
+
/**
|
|
230
|
+
* @deprecated Use {@link commitBatchProgress}. This method advances the
|
|
231
|
+
* cursor without the ownership fence, so a stale delivery can move the
|
|
232
|
+
* cursor of a run another worker owns. Kept for external callers only.
|
|
233
|
+
*/
|
|
198
234
|
async updateCursor(runId: string, cursor: string, scope: SyncScope): Promise<void> {
|
|
199
235
|
const run = await this.getRun(runId, scope)
|
|
200
236
|
if (!run) return
|
|
@@ -204,16 +240,61 @@ export function createSyncRunService(em: EntityManager) {
|
|
|
204
240
|
], { transaction: true })
|
|
205
241
|
},
|
|
206
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Commits one batch's counters and cursor in a single transaction.
|
|
245
|
+
*
|
|
246
|
+
* Passing `expectedBatchesCompleted` fences the write: the run must still be
|
|
247
|
+
* `running` and still sit on that batch count, or another delivery of the
|
|
248
|
+
* same BullMQ job owns the run and this commit throws
|
|
249
|
+
* `SyncRunOwnershipConflictError` and rolls back. Omitting it keeps the
|
|
250
|
+
* legacy unguarded write for callers outside the engine.
|
|
251
|
+
*
|
|
252
|
+
* The fence token is `batchesCompleted` rather than `cursor` because it
|
|
253
|
+
* advances by construction on every commit. A cursor is a free-form adapter
|
|
254
|
+
* string that an adapter may legitimately repeat between batches — the
|
|
255
|
+
* Akeneo products adapter does, between its final page and the
|
|
256
|
+
* reconciliation batch that follows it — and a repeated token fences
|
|
257
|
+
* nothing.
|
|
258
|
+
*
|
|
259
|
+
* The guard's `UPDATE` also holds the row lock for the rest of the
|
|
260
|
+
* transaction, so a competing commit blocks here and then re-reads the
|
|
261
|
+
* advanced count instead of interleaving with this one. That is what lets
|
|
262
|
+
* the counters below stay a plain read-modify-write against the snapshot
|
|
263
|
+
* read above: a commit that wins the fence has proven that nothing else
|
|
264
|
+
* landed since it read.
|
|
265
|
+
*/
|
|
207
266
|
async commitBatchProgress(
|
|
208
267
|
runId: string,
|
|
209
268
|
delta: Partial<Pick<SyncRun, 'createdCount' | 'updatedCount' | 'skippedCount' | 'failedCount' | 'batchesCompleted'>>,
|
|
210
269
|
cursor: string,
|
|
211
270
|
scope: SyncScope,
|
|
271
|
+
expectedBatchesCompleted?: number,
|
|
212
272
|
): Promise<SyncRun | null> {
|
|
213
273
|
const run = await this.getRun(runId, scope)
|
|
214
274
|
if (!run) return null
|
|
215
275
|
const cursorRow = await resolveCursorRow(run, scope)
|
|
276
|
+
const claimRunOwnership = async () => {
|
|
277
|
+
if ((delta.batchesCompleted ?? 0) < 1) {
|
|
278
|
+
throw new Error(`[internal] A fenced commit for sync run ${runId} must advance batchesCompleted`)
|
|
279
|
+
}
|
|
280
|
+
const owned = await em.nativeUpdate(
|
|
281
|
+
SyncRun,
|
|
282
|
+
{
|
|
283
|
+
id: runId,
|
|
284
|
+
organizationId: scope.organizationId,
|
|
285
|
+
tenantId: scope.tenantId,
|
|
286
|
+
deletedAt: null,
|
|
287
|
+
status: 'running',
|
|
288
|
+
batchesCompleted: expectedBatchesCompleted,
|
|
289
|
+
},
|
|
290
|
+
{ updatedAt: new Date() },
|
|
291
|
+
)
|
|
292
|
+
if (owned === 0) {
|
|
293
|
+
throw new SyncRunOwnershipConflictError(runId, expectedBatchesCompleted ?? 0)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
216
296
|
await withAtomicFlush(em, [
|
|
297
|
+
...(expectedBatchesCompleted === undefined ? [] : [claimRunOwnership]),
|
|
217
298
|
() => {
|
|
218
299
|
run.createdCount += delta.createdCount ?? 0
|
|
219
300
|
run.updatedCount += delta.updatedCount ?? 0
|
|
@@ -2,6 +2,11 @@ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
|
2
2
|
import type { ProgressService } from '../../progress/lib/progressService'
|
|
3
3
|
import type { SyncEngine } from '../lib/sync-engine'
|
|
4
4
|
import type { SyncRunService } from '../lib/sync-run-service'
|
|
5
|
+
import {
|
|
6
|
+
DATA_SYNC_EXPORT_QUEUE,
|
|
7
|
+
DATA_SYNC_LOCK_DURATION_MS,
|
|
8
|
+
DATA_SYNC_MAX_STALLED_COUNT,
|
|
9
|
+
} from '../lib/queue-policy'
|
|
5
10
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
11
|
|
|
7
12
|
const logger = createLogger('data_sync').child({ component: 'sync-export' })
|
|
@@ -17,9 +22,11 @@ type SyncJobPayload = {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export const metadata: WorkerMeta = {
|
|
20
|
-
queue:
|
|
25
|
+
queue: DATA_SYNC_EXPORT_QUEUE,
|
|
21
26
|
id: 'data-sync:export',
|
|
22
27
|
concurrency: 5,
|
|
28
|
+
lockDuration: DATA_SYNC_LOCK_DURATION_MS,
|
|
29
|
+
maxStalledCount: DATA_SYNC_MAX_STALLED_COUNT,
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
type HandlerContext = JobContext & {
|
|
@@ -2,6 +2,11 @@ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
|
2
2
|
import type { ProgressService } from '../../progress/lib/progressService'
|
|
3
3
|
import type { SyncEngine } from '../lib/sync-engine'
|
|
4
4
|
import type { SyncRunService } from '../lib/sync-run-service'
|
|
5
|
+
import {
|
|
6
|
+
DATA_SYNC_IMPORT_QUEUE,
|
|
7
|
+
DATA_SYNC_LOCK_DURATION_MS,
|
|
8
|
+
DATA_SYNC_MAX_STALLED_COUNT,
|
|
9
|
+
} from '../lib/queue-policy'
|
|
5
10
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
11
|
|
|
7
12
|
const logger = createLogger('data_sync').child({ component: 'sync-import' })
|
|
@@ -17,9 +22,11 @@ type SyncJobPayload = {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export const metadata: WorkerMeta = {
|
|
20
|
-
queue:
|
|
25
|
+
queue: DATA_SYNC_IMPORT_QUEUE,
|
|
21
26
|
id: 'data-sync:import',
|
|
22
27
|
concurrency: 5,
|
|
28
|
+
lockDuration: DATA_SYNC_LOCK_DURATION_MS,
|
|
29
|
+
maxStalledCount: DATA_SYNC_MAX_STALLED_COUNT,
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
type HandlerContext = JobContext & {
|
|
@@ -3,6 +3,7 @@ import type { EntityManager } from '@mikro-orm/core'
|
|
|
3
3
|
import { Notification } from '../data/entities'
|
|
4
4
|
import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
|
|
5
5
|
import { toNotificationDto } from '../lib/notificationMapper'
|
|
6
|
+
import { buildNotificationReadScopeWhere } from '../lib/notificationScope'
|
|
6
7
|
import {
|
|
7
8
|
NOTIFICATION_RESOURCE_KIND,
|
|
8
9
|
notificationCrudErrorResponse,
|
|
@@ -32,6 +33,7 @@ export async function GET(req: Request) {
|
|
|
32
33
|
const filters: Record<string, unknown> = {
|
|
33
34
|
recipientUserId: scope.userId,
|
|
34
35
|
tenantId: scope.tenantId,
|
|
36
|
+
...buildNotificationReadScopeWhere(scope),
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
if (input.status) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import type { EntityManager } from '@mikro-orm/core'
|
|
2
3
|
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
3
4
|
import {
|
|
@@ -8,6 +9,10 @@ import {
|
|
|
8
9
|
import { Notification } from '../../data/entities'
|
|
9
10
|
import { unreadCountResponseSchema } from '../openapi'
|
|
10
11
|
import { resolveNotificationContext } from '../../lib/routeHelpers'
|
|
12
|
+
import {
|
|
13
|
+
buildNotificationReadScopeWhere,
|
|
14
|
+
getNotificationReadScopeTagOrganizationIds,
|
|
15
|
+
} from '../../lib/notificationScope'
|
|
11
16
|
|
|
12
17
|
export const metadata = {
|
|
13
18
|
GET: { requireAuth: true },
|
|
@@ -16,8 +21,21 @@ export const metadata = {
|
|
|
16
21
|
const UNREAD_COUNT_RESOURCE = 'notifications.notification'
|
|
17
22
|
const UNREAD_COUNT_TTL_MS = 10_000
|
|
18
23
|
|
|
19
|
-
function buildUnreadCountCacheKey(
|
|
20
|
-
|
|
24
|
+
function buildUnreadCountCacheKey(params: {
|
|
25
|
+
userId: string
|
|
26
|
+
organizationId: string | null
|
|
27
|
+
organizationIds: string[]
|
|
28
|
+
}): string {
|
|
29
|
+
const normalizedIds = Array.from(new Set(
|
|
30
|
+
params.organizationIds.filter((value) => value.trim().length > 0),
|
|
31
|
+
)).sort((left, right) => left.localeCompare(right))
|
|
32
|
+
const scopeKey = normalizedIds.length === 0
|
|
33
|
+
? 'no-access'
|
|
34
|
+
: `${params.organizationId ?? 'none'}:scope=${createHash('sha256')
|
|
35
|
+
.update(normalizedIds.join('\0'))
|
|
36
|
+
.digest('hex')
|
|
37
|
+
.slice(0, 16)}`
|
|
38
|
+
return `notifications:unread-count:u=${params.userId}:org=${scopeKey}`
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
export async function GET(req: Request) {
|
|
@@ -25,8 +43,22 @@ export async function GET(req: Request) {
|
|
|
25
43
|
const em = ctx.container.resolve('em') as EntityManager
|
|
26
44
|
|
|
27
45
|
const userId = scope.userId
|
|
28
|
-
const
|
|
29
|
-
|
|
46
|
+
const cacheableOrganizationIds = Array.isArray(scope.organizationIds)
|
|
47
|
+
? scope.organizationIds
|
|
48
|
+
: null
|
|
49
|
+
// Unrestricted and omitted legacy scopes cannot be safely tagged for invalidation:
|
|
50
|
+
// org-specific writes only invalidate their own collection tag. Keep those scopes
|
|
51
|
+
// uncached rather than serving a stale tenant-wide total until the TTL expires.
|
|
52
|
+
const cache = userId && cacheableOrganizationIds && isCrudCacheEnabled()
|
|
53
|
+
? resolveCrudCache(ctx.container)
|
|
54
|
+
: null
|
|
55
|
+
const cacheKey = cache && userId && cacheableOrganizationIds
|
|
56
|
+
? buildUnreadCountCacheKey({
|
|
57
|
+
userId,
|
|
58
|
+
organizationId: scope.organizationId,
|
|
59
|
+
organizationIds: cacheableOrganizationIds,
|
|
60
|
+
})
|
|
61
|
+
: null
|
|
30
62
|
|
|
31
63
|
if (cache && cacheKey) {
|
|
32
64
|
try {
|
|
@@ -41,6 +73,7 @@ export async function GET(req: Request) {
|
|
|
41
73
|
recipientUserId: userId,
|
|
42
74
|
tenantId: scope.tenantId,
|
|
43
75
|
status: 'unread',
|
|
76
|
+
...buildNotificationReadScopeWhere(scope),
|
|
44
77
|
})
|
|
45
78
|
|
|
46
79
|
if (cache && cacheKey) {
|
|
@@ -48,7 +81,11 @@ export async function GET(req: Request) {
|
|
|
48
81
|
await runWithCacheTenant(scope.tenantId, () =>
|
|
49
82
|
cache.set(cacheKey, count, {
|
|
50
83
|
ttl: UNREAD_COUNT_TTL_MS,
|
|
51
|
-
tags: buildCollectionTags(
|
|
84
|
+
tags: buildCollectionTags(
|
|
85
|
+
UNREAD_COUNT_RESOURCE,
|
|
86
|
+
scope.tenantId,
|
|
87
|
+
getNotificationReadScopeTagOrganizationIds(scope),
|
|
88
|
+
),
|
|
52
89
|
}),
|
|
53
90
|
)
|
|
54
91
|
} catch {}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type NotificationReadOrganizationScope = {
|
|
2
|
+
organizationId?: string | null
|
|
3
|
+
organizationIds?: string[] | null
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type NotificationReadScopeWhere = {
|
|
7
|
+
organizationId?: null
|
|
8
|
+
$or?: Array<
|
|
9
|
+
| { organizationId: { $in: string[] } }
|
|
10
|
+
| { organizationId: null }
|
|
11
|
+
>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveOrganizationIds(scope: NotificationReadOrganizationScope): string[] | null {
|
|
15
|
+
if (scope.organizationIds === null) return null
|
|
16
|
+
if (scope.organizationIds === undefined) return null
|
|
17
|
+
const candidates = scope.organizationIds
|
|
18
|
+
return Array.from(new Set(candidates.filter((value) => value.trim().length > 0)))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildNotificationReadScopeWhere(
|
|
22
|
+
scope: NotificationReadOrganizationScope,
|
|
23
|
+
): NotificationReadScopeWhere {
|
|
24
|
+
const organizationIds = resolveOrganizationIds(scope)
|
|
25
|
+
if (organizationIds === null) return {}
|
|
26
|
+
if (organizationIds.length === 0) return { organizationId: null }
|
|
27
|
+
return {
|
|
28
|
+
$or: [
|
|
29
|
+
{ organizationId: { $in: organizationIds } },
|
|
30
|
+
{ organizationId: null },
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getNotificationReadScopeTagOrganizationIds(
|
|
36
|
+
scope: NotificationReadOrganizationScope,
|
|
37
|
+
): Array<string | null> {
|
|
38
|
+
const organizationIds = resolveOrganizationIds(scope)
|
|
39
|
+
return organizationIds === null ? [null] : [...organizationIds, null]
|
|
40
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type NotificationTenantContext,
|
|
15
15
|
} from './notificationFactory'
|
|
16
16
|
import { toNotificationDto } from './notificationMapper'
|
|
17
|
+
import { buildNotificationReadScopeWhere } from './notificationScope'
|
|
17
18
|
import {
|
|
18
19
|
getRecipientUserIdsForFeature,
|
|
19
20
|
getRecipientUserIdsForRole,
|
|
@@ -184,6 +185,7 @@ async function createOrRefreshNotification(
|
|
|
184
185
|
export interface NotificationServiceContext {
|
|
185
186
|
tenantId: string
|
|
186
187
|
organizationId?: string | null
|
|
188
|
+
organizationIds?: string[] | null
|
|
187
189
|
userId?: string | null
|
|
188
190
|
}
|
|
189
191
|
|
|
@@ -594,6 +596,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
594
596
|
recipientUserId: ctx.userId,
|
|
595
597
|
tenantId: ctx.tenantId,
|
|
596
598
|
status: 'unread',
|
|
599
|
+
...buildNotificationReadScopeWhere(ctx),
|
|
597
600
|
})
|
|
598
601
|
},
|
|
599
602
|
|
|
@@ -602,6 +605,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
602
605
|
const filters: Record<string, unknown> = {
|
|
603
606
|
recipientUserId: ctx.userId,
|
|
604
607
|
tenantId: ctx.tenantId,
|
|
608
|
+
...buildNotificationReadScopeWhere(ctx),
|
|
605
609
|
}
|
|
606
610
|
|
|
607
611
|
if (since) {
|
|
@@ -617,6 +621,7 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
617
621
|
recipientUserId: ctx.userId,
|
|
618
622
|
tenantId: ctx.tenantId,
|
|
619
623
|
status: 'unread',
|
|
624
|
+
...buildNotificationReadScopeWhere(ctx),
|
|
620
625
|
}),
|
|
621
626
|
])
|
|
622
627
|
|
|
@@ -4,6 +4,7 @@ import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
|
|
|
4
4
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
5
|
import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
|
|
6
6
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
7
8
|
import { resolveNotificationService, type NotificationService } from './notificationService'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -22,6 +23,7 @@ export const NOTIFICATION_SETTINGS_RESOURCE_KIND = 'notifications.settings'
|
|
|
22
23
|
export interface NotificationScope {
|
|
23
24
|
tenantId: string
|
|
24
25
|
organizationId: string | null
|
|
26
|
+
organizationIds?: string[] | null
|
|
25
27
|
userId: string | null
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -64,11 +66,26 @@ export function notificationCrudErrorResponse(error: unknown): Response | null {
|
|
|
64
66
|
*/
|
|
65
67
|
export async function resolveNotificationContext(req: Request): Promise<NotificationRequestContext> {
|
|
66
68
|
const { ctx } = await resolveRequestContext(req)
|
|
69
|
+
const organizationScope = await resolveOrganizationScopeForRequest({
|
|
70
|
+
container: ctx.container,
|
|
71
|
+
auth: ctx.auth,
|
|
72
|
+
request: req,
|
|
73
|
+
...(ctx.selectedOrganizationId === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { selectedId: ctx.selectedOrganizationId }),
|
|
76
|
+
})
|
|
77
|
+
const tenantId = organizationScope.tenantId ?? ctx.auth?.tenantId ?? ''
|
|
78
|
+
const organizationId = organizationScope.selectedId
|
|
79
|
+
const organizationIds = organizationScope.filterIds
|
|
80
|
+
ctx.organizationScope = organizationScope
|
|
81
|
+
ctx.selectedOrganizationId = organizationId
|
|
82
|
+
ctx.organizationIds = organizationIds
|
|
67
83
|
return {
|
|
68
84
|
service: resolveNotificationService(ctx.container),
|
|
69
85
|
scope: {
|
|
70
|
-
tenantId
|
|
71
|
-
organizationId
|
|
86
|
+
tenantId,
|
|
87
|
+
organizationId,
|
|
88
|
+
organizationIds,
|
|
72
89
|
userId: ctx.auth?.sub ?? null,
|
|
73
90
|
},
|
|
74
91
|
ctx,
|