@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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/profile/route.js +8 -2
  3. package/dist/modules/auth/api/profile/route.js.map +2 -2
  4. package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
  5. package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
  6. package/dist/modules/data_sync/lib/queue-policy.js +15 -0
  7. package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
  8. package/dist/modules/data_sync/lib/queue.js +16 -1
  9. package/dist/modules/data_sync/lib/queue.js.map +2 -2
  10. package/dist/modules/data_sync/lib/start-run.js +2 -1
  11. package/dist/modules/data_sync/lib/start-run.js.map +2 -2
  12. package/dist/modules/data_sync/lib/sync-engine.js +35 -2
  13. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  14. package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
  15. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  16. package/dist/modules/data_sync/workers/sync-export.js +9 -2
  17. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  18. package/dist/modules/data_sync/workers/sync-import.js +9 -2
  19. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  20. package/dist/modules/notifications/api/route.js +3 -1
  21. package/dist/modules/notifications/api/route.js.map +2 -2
  22. package/dist/modules/notifications/api/unread-count/route.js +25 -6
  23. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  24. package/dist/modules/notifications/lib/notificationScope.js +26 -0
  25. package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
  26. package/dist/modules/notifications/lib/notificationService.js +7 -3
  27. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  28. package/dist/modules/notifications/lib/routeHelpers.js +16 -2
  29. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  30. package/package.json +7 -7
  31. package/src/modules/auth/api/profile/route.ts +8 -2
  32. package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
  33. package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
  34. package/src/modules/data_sync/lib/adapter.ts +18 -0
  35. package/src/modules/data_sync/lib/queue-policy.ts +30 -0
  36. package/src/modules/data_sync/lib/queue.ts +19 -1
  37. package/src/modules/data_sync/lib/start-run.ts +2 -1
  38. package/src/modules/data_sync/lib/sync-engine.ts +46 -0
  39. package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
  40. package/src/modules/data_sync/workers/sync-export.ts +8 -1
  41. package/src/modules/data_sync/workers/sync-import.ts +8 -1
  42. package/src/modules/notifications/api/route.ts +2 -0
  43. package/src/modules/notifications/api/unread-count/route.ts +42 -5
  44. package/src/modules/notifications/lib/notificationScope.ts +40 -0
  45. package/src/modules/notifications/lib/notificationService.ts +5 -0
  46. 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
- status: 'pending',
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: 'data-sync-export',
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: 'data-sync-import',
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(userId: string): string {
20
- return `notifications:unread-count:u=${userId}`
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 cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null
29
- const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null
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(UNREAD_COUNT_RESOURCE, scope.tenantId, [null]),
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: ctx.auth?.tenantId ?? '',
71
- organizationId: ctx.selectedOrganizationId ?? null,
86
+ tenantId,
87
+ organizationId,
88
+ organizationIds,
72
89
  userId: ctx.auth?.sub ?? null,
73
90
  },
74
91
  ctx,