@open-mercato/core 0.6.7-develop.6726.1.983ae8a07e → 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 (191) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/resources_resource/index.js +2 -0
  3. package/dist/generated/entities/resources_resource/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/salesFixtures.js +31 -2
  7. package/dist/helpers/integration/salesFixtures.js.map +2 -2
  8. package/dist/helpers/integration/salesUi.js +5 -0
  9. package/dist/helpers/integration/salesUi.js.map +2 -2
  10. package/dist/modules/auth/api/profile/route.js +8 -2
  11. package/dist/modules/auth/api/profile/route.js.map +2 -2
  12. package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
  13. package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
  14. package/dist/modules/communication_channels/data/enrichers.js +4 -4
  15. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  16. package/dist/modules/customers/analytics.js +1 -0
  17. package/dist/modules/customers/analytics.js.map +2 -2
  18. package/dist/modules/customers/components/detail/DealForm.js +2 -1
  19. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  20. package/dist/modules/customers/components/detail/DealsSection.js +1 -1
  21. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  22. package/dist/modules/customers/data/validators.js +3 -1
  23. package/dist/modules/customers/data/validators.js.map +2 -2
  24. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
  25. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
  26. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
  27. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  28. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
  29. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +2 -2
  30. package/dist/modules/data_sync/lib/queue-policy.js +15 -0
  31. package/dist/modules/data_sync/lib/queue-policy.js.map +7 -0
  32. package/dist/modules/data_sync/lib/queue.js +16 -1
  33. package/dist/modules/data_sync/lib/queue.js.map +2 -2
  34. package/dist/modules/data_sync/lib/start-run.js +2 -1
  35. package/dist/modules/data_sync/lib/start-run.js.map +2 -2
  36. package/dist/modules/data_sync/lib/sync-engine.js +35 -2
  37. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-run-service.js +70 -2
  39. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  40. package/dist/modules/data_sync/workers/sync-export.js +9 -2
  41. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  42. package/dist/modules/data_sync/workers/sync-import.js +9 -2
  43. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  44. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
  45. package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
  46. package/dist/modules/dictionaries/api/openapi.js +15 -2
  47. package/dist/modules/dictionaries/api/openapi.js.map +2 -2
  48. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
  49. package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
  50. package/dist/modules/dictionaries/data/validators.js +7 -0
  51. package/dist/modules/dictionaries/data/validators.js.map +2 -2
  52. package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
  53. package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
  54. package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
  55. package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
  56. package/dist/modules/entities/api/definitions.js +51 -3
  57. package/dist/modules/entities/api/definitions.js.map +2 -2
  58. package/dist/modules/entities/api/entities.js +21 -2
  59. package/dist/modules/entities/api/entities.js.map +2 -2
  60. package/dist/modules/entities/api/records.js +3 -24
  61. package/dist/modules/entities/api/records.js.map +2 -2
  62. package/dist/modules/entities/api/sidebar-entities.js +34 -12
  63. package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
  64. package/dist/modules/entities/lib/entityAcl.js +46 -0
  65. package/dist/modules/entities/lib/entityAcl.js.map +2 -2
  66. package/dist/modules/messages/api/route.js +2 -5
  67. package/dist/modules/messages/api/route.js.map +2 -2
  68. package/dist/modules/messages/lib/participantScope.js +15 -0
  69. package/dist/modules/messages/lib/participantScope.js.map +7 -0
  70. package/dist/modules/notifications/api/route.js +3 -1
  71. package/dist/modules/notifications/api/route.js.map +2 -2
  72. package/dist/modules/notifications/api/unread-count/route.js +25 -6
  73. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  74. package/dist/modules/notifications/lib/notificationScope.js +26 -0
  75. package/dist/modules/notifications/lib/notificationScope.js.map +7 -0
  76. package/dist/modules/notifications/lib/notificationService.js +7 -3
  77. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  78. package/dist/modules/notifications/lib/routeHelpers.js +16 -2
  79. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  80. package/dist/modules/planner/components/unavailabilityReasons.js +3 -3
  81. package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
  82. package/dist/modules/progress/lib/progressService.js +2 -0
  83. package/dist/modules/progress/lib/progressService.js.map +2 -2
  84. package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
  85. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  86. package/dist/modules/resources/api/resources.js +2 -0
  87. package/dist/modules/resources/api/resources.js.map +2 -2
  88. package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
  89. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  90. package/dist/modules/resources/commands/resources.js +9 -0
  91. package/dist/modules/resources/commands/resources.js.map +2 -2
  92. package/dist/modules/resources/components/detail/dictionaries.js +3 -3
  93. package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
  94. package/dist/modules/resources/data/entities.js +3 -0
  95. package/dist/modules/resources/data/entities.js.map +2 -2
  96. package/dist/modules/resources/data/validators.js +6 -2
  97. package/dist/modules/resources/data/validators.js.map +2 -2
  98. package/dist/modules/resources/lib/seeds.js +16 -9
  99. package/dist/modules/resources/lib/seeds.js.map +2 -2
  100. package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
  101. package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
  102. package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
  103. package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
  104. package/dist/modules/sales/commands/documents.js +8 -0
  105. package/dist/modules/sales/commands/documents.js.map +2 -2
  106. package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
  107. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  108. package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
  109. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  110. package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
  111. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
  113. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
  114. package/dist/modules/sales/data/validators.js +5 -1
  115. package/dist/modules/sales/data/validators.js.map +2 -2
  116. package/dist/modules/staff/components/detail/dictionaries.js +3 -3
  117. package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
  118. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
  119. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  120. package/generated/entities/resources_resource/index.ts +1 -0
  121. package/generated/entity-fields-registry.ts +1 -0
  122. package/package.json +7 -7
  123. package/src/helpers/integration/salesFixtures.ts +34 -2
  124. package/src/helpers/integration/salesUi.ts +8 -0
  125. package/src/modules/auth/api/profile/route.ts +8 -2
  126. package/src/modules/auth/backend/auth/profile/page.tsx +1 -0
  127. package/src/modules/auth/backend/profile/change-password/page.tsx +1 -0
  128. package/src/modules/communication_channels/data/enrichers.ts +6 -26
  129. package/src/modules/customers/analytics.ts +1 -0
  130. package/src/modules/customers/components/detail/DealForm.tsx +5 -1
  131. package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
  132. package/src/modules/customers/data/validators.ts +5 -1
  133. package/src/modules/dashboards/i18n/de.json +3 -0
  134. package/src/modules/dashboards/i18n/en.json +3 -0
  135. package/src/modules/dashboards/i18n/es.json +3 -0
  136. package/src/modules/dashboards/i18n/pl.json +3 -0
  137. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
  138. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
  139. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
  140. package/src/modules/data_sync/lib/adapter.ts +18 -0
  141. package/src/modules/data_sync/lib/queue-policy.ts +30 -0
  142. package/src/modules/data_sync/lib/queue.ts +19 -1
  143. package/src/modules/data_sync/lib/start-run.ts +2 -1
  144. package/src/modules/data_sync/lib/sync-engine.ts +46 -0
  145. package/src/modules/data_sync/lib/sync-run-service.ts +82 -1
  146. package/src/modules/data_sync/workers/sync-export.ts +8 -1
  147. package/src/modules/data_sync/workers/sync-import.ts +8 -1
  148. package/src/modules/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
  149. package/src/modules/dictionaries/api/openapi.ts +19 -0
  150. package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
  151. package/src/modules/dictionaries/data/validators.ts +14 -0
  152. package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
  153. package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
  154. package/src/modules/entities/api/definitions.ts +51 -3
  155. package/src/modules/entities/api/entities.ts +30 -2
  156. package/src/modules/entities/api/records.ts +3 -27
  157. package/src/modules/entities/api/sidebar-entities.ts +43 -14
  158. package/src/modules/entities/lib/entityAcl.ts +58 -0
  159. package/src/modules/messages/api/route.ts +4 -5
  160. package/src/modules/messages/lib/participantScope.ts +64 -0
  161. package/src/modules/notifications/api/route.ts +2 -0
  162. package/src/modules/notifications/api/unread-count/route.ts +42 -5
  163. package/src/modules/notifications/lib/notificationScope.ts +40 -0
  164. package/src/modules/notifications/lib/notificationService.ts +5 -0
  165. package/src/modules/notifications/lib/routeHelpers.ts +19 -2
  166. package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
  167. package/src/modules/progress/AGENTS.md +11 -1
  168. package/src/modules/progress/lib/progressService.ts +1 -0
  169. package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
  170. package/src/modules/resources/api/resources.ts +2 -0
  171. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
  172. package/src/modules/resources/commands/resources.ts +10 -0
  173. package/src/modules/resources/components/detail/dictionaries.ts +3 -3
  174. package/src/modules/resources/data/entities.ts +3 -0
  175. package/src/modules/resources/data/validators.ts +5 -0
  176. package/src/modules/resources/lib/seeds.ts +16 -9
  177. package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
  178. package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
  179. package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
  180. package/src/modules/sales/commands/documents.ts +8 -0
  181. package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
  182. package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
  183. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
  184. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
  185. package/src/modules/sales/data/validators.ts +7 -1
  186. package/src/modules/sales/i18n/de.json +2 -0
  187. package/src/modules/sales/i18n/en.json +2 -0
  188. package/src/modules/sales/i18n/es.json +2 -0
  189. package/src/modules/sales/i18n/pl.json +2 -0
  190. package/src/modules/staff/components/detail/dictionaries.ts +3 -3
  191. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +12 -5
@@ -1,6 +1,7 @@
1
1
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
2
  import { hasAllFeatures } from '@open-mercato/shared/security/features'
3
3
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
4
+ import { getModules } from '@open-mercato/shared/lib/i18n/server'
4
5
  import { deriveCustomEntityRecordFeature } from './recordFeatures'
5
6
 
6
7
  export type EntityAclRequirement = {
@@ -65,10 +66,67 @@ const ENTITY_ACL_REQUIREMENTS: Record<string, EntityAclRequirement> = {
65
66
  },
66
67
  }
67
68
 
69
+ let declaredCustomEntityRestrictions: Map<string, boolean> | null = null
70
+
71
+ function loadDeclaredCustomEntityRestrictions(): Map<string, boolean> {
72
+ if (declaredCustomEntityRestrictions === null) {
73
+ try {
74
+ const modules = getModules() as Array<{
75
+ customEntities?: Array<{ id?: string; accessRestricted?: boolean }>
76
+ }>
77
+ const restrictions = new Map<string, boolean>()
78
+ for (const moduleEntry of modules ?? []) {
79
+ for (const spec of moduleEntry.customEntities ?? []) {
80
+ if (spec.id) restrictions.set(spec.id, spec.accessRestricted === true)
81
+ }
82
+ }
83
+ declaredCustomEntityRestrictions = restrictions
84
+ } catch {}
85
+ }
86
+ return declaredCustomEntityRestrictions ?? new Map<string, boolean>()
87
+ }
88
+
89
+ export function getDeclaredCustomEntityRestriction(entityId: string): boolean | undefined {
90
+ return loadDeclaredCustomEntityRestrictions().get(entityId)
91
+ }
92
+
93
+ export function isDeclaredCustomEntity(entityId: string): boolean {
94
+ return loadDeclaredCustomEntityRestrictions().has(entityId)
95
+ }
96
+
68
97
  export function resolveEntityAclRequirement(entityId: string): EntityAclRequirement | null {
69
98
  return ENTITY_ACL_REQUIREMENTS[entityId] ?? null
70
99
  }
71
100
 
101
+ export function canReadAllEntityMetadata(acl: {
102
+ isSuperAdmin?: boolean
103
+ features?: readonly string[]
104
+ }): boolean {
105
+ return Boolean(acl.isSuperAdmin) || hasAllFeatures(acl.features, ['entities.definitions.view'])
106
+ }
107
+
108
+ export function canReadEntityMetadata(args: {
109
+ entityId: string
110
+ isCustomEntity: boolean
111
+ isRestricted?: boolean
112
+ acl: { isSuperAdmin?: boolean; features?: readonly string[] }
113
+ }): boolean {
114
+ if (args.acl.isSuperAdmin) return true
115
+
116
+ const requirement = resolveEntityAclRequirement(args.entityId)
117
+ if (requirement?.platformOnly) return false
118
+ if (canReadAllEntityMetadata(args.acl)) return true
119
+ if (args.isCustomEntity) {
120
+ const requiredFeatures = ['entities.records.view']
121
+ if (args.isRestricted) {
122
+ requiredFeatures.push(deriveCustomEntityRecordFeature(args.entityId, 'view'))
123
+ }
124
+ return hasAllFeatures(args.acl.features, requiredFeatures)
125
+ }
126
+ if (!requirement) return false
127
+ return hasAllFeatures(args.acl.features, requirement.view)
128
+ }
129
+
72
130
  type EntityAclActor = {
73
131
  sub?: string | null
74
132
  tenantId?: string | null
@@ -13,6 +13,7 @@ import { getMessageType } from '../lib/message-types-registry'
13
13
  import { validateMessageObjectsForType } from '../lib/object-validation'
14
14
  import { attachOperationMetadataHeader } from '../lib/operationMetadata'
15
15
  import { canUseMessageEmailFeature, resolveMessageContext } from '../lib/routeHelpers'
16
+ import { applyMessageParticipantScope } from '../lib/participantScope'
16
17
  import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from './guards'
17
18
  import { findMessageIdsBySearchTokens } from '../lib/searchLookup'
18
19
  import { MessageCommandExecuteResult } from '../commands/shared'
@@ -120,11 +121,9 @@ export async function GET(req: Request) {
120
121
  joinRecipient()
121
122
  break
122
123
  case 'all':
123
- joinRecipient()
124
- q = q.where((eb: any) => eb.or([
125
- eb('m.sender_user_id', '=', scope.userId),
126
- eb('r.message_id', 'is not', null),
127
- ]))
124
+ // Sender-OR-recipient participant scope shared with the
125
+ // communication_channels message enricher — see participantScope.ts (#4133).
126
+ q = applyMessageParticipantScope(q, scope.userId)
128
127
  break
129
128
  default: {
130
129
  const unsupportedFolder: never = input.folder
@@ -0,0 +1,64 @@
1
+ import type { SelectQueryBuilder } from 'kysely'
2
+
3
+ /**
4
+ * Minimal Kysely schema contract the message participant-scope predicate depends
5
+ * on. Both the messages list route (`api/route.ts`, `all` folder) and the
6
+ * `communication_channels.message-channel` response enricher build their
7
+ * sender-OR-recipient access filter from {@link applyMessageParticipantScope},
8
+ * so a column rename (`sender_user_id`, `recipient_user_id`, `deleted_at`) or a
9
+ * change to the recipient-visibility rules updates both call sites at once
10
+ * instead of silently desyncing the enricher's security boundary from the list
11
+ * route (#4133, follow-up to #4099).
12
+ */
13
+ export type MessagesParticipantScopeDatabase = {
14
+ messages: {
15
+ id: string
16
+ tenant_id: string
17
+ organization_id: string | null
18
+ sender_user_id: string
19
+ deleted_at: Date | null
20
+ }
21
+ message_recipients: {
22
+ message_id: string
23
+ recipient_user_id: string
24
+ deleted_at: Date | null
25
+ }
26
+ }
27
+
28
+ type MessagesTable = MessagesParticipantScopeDatabase['messages']
29
+ type MessageRecipientsTable = MessagesParticipantScopeDatabase['message_recipients']
30
+
31
+ type MessagesFrom = MessagesParticipantScopeDatabase & { m: MessagesTable }
32
+ type MessagesJoinedFrom = MessagesFrom & { r: MessageRecipientsTable }
33
+
34
+ /**
35
+ * Apply the shared message participant-scope predicate to a query already built
36
+ * from `messages as m`. A message is visible to `userId` when they are the
37
+ * sender OR a non-deleted recipient. The recipient soft-delete rule
38
+ * (`r.deleted_at is null`) lives inside the recipient join, so it is part of the
39
+ * single source of truth — the recipient-visibility boundary cannot drift
40
+ * between the list route and the enricher.
41
+ *
42
+ * Message-level tenant / organization / soft-delete scoping stays with the
43
+ * caller (both call sites already apply it uniformly to every query), but the
44
+ * shared {@link MessagesParticipantScopeDatabase} type keeps those column names
45
+ * coupled at compile time as well.
46
+ */
47
+ export function applyMessageParticipantScope<O>(
48
+ query: SelectQueryBuilder<MessagesFrom, 'm', O>,
49
+ userId: string,
50
+ ): SelectQueryBuilder<MessagesJoinedFrom, 'm' | 'r', O> {
51
+ return query
52
+ .leftJoin('message_recipients as r', (join) =>
53
+ join
54
+ .onRef('m.id', '=', 'r.message_id')
55
+ .on('r.recipient_user_id', '=', userId)
56
+ .on('r.deleted_at', 'is', null),
57
+ )
58
+ .where((eb) =>
59
+ eb.or([
60
+ eb('m.sender_user_id', '=', userId),
61
+ eb('r.message_id', 'is not', null),
62
+ ]),
63
+ ) as unknown as SelectQueryBuilder<MessagesJoinedFrom, 'm' | 'r', O>
64
+ }
@@ -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,
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
4
+ import { fetchAllDictionaryEntries } from '@open-mercato/core/modules/dictionaries/lib/fetchAllEntries'
4
5
  import {
5
6
  resolveUnavailabilityReasonDictionary,
6
7
  type UnavailabilityReasonSubjectType,
@@ -45,10 +46,9 @@ export async function loadUnavailabilityReasonEntries(
45
46
  const dictionary = resolveUnavailabilityReasonDictionary(subjectType)
46
47
  const resolved = await ensureDictionary(dictionary.key, dictionary.name)
47
48
  if (!resolved) return []
48
- const entriesCall = await apiCall<{ items?: Record<string, unknown>[] }>(`/api/dictionaries/${resolved.id}/entries`)
49
+ const entriesCall = await fetchAllDictionaryEntries(resolved.id)
49
50
  if (!entriesCall.ok) return []
50
- const items = Array.isArray(entriesCall.result?.items) ? entriesCall.result?.items ?? [] : []
51
- return items
51
+ return entriesCall.items
52
52
  .map((entry) => {
53
53
  if (!entry || typeof entry !== 'object') return null
54
54
  const record = entry as Record<string, unknown>
@@ -86,7 +86,17 @@ Use stable, grep-friendly ids:
86
86
 
87
87
  | Variable | Effect | Default |
88
88
  |----------|--------|---------|
89
- | `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS` | Coalesces intermediate `progress.job.updated` flush+broadcasts per job: the service flushes and emits only when this many ms elapsed since the last broadcast **or** `progressPercent` advanced by ≥1; sub-threshold updates buffer in memory. Keeps bulk workers from firing one serialized `pg_notify` roundtrip + tenant-wide SSE fan-out per record. Terminal events (`created`/`started`/`completed`/`failed`/`cancelled`) are never throttled, so `ProgressTopBar` still converges and the 60s `STALE_JOB_TIMEOUT_SECONDS` heartbeat window is never exceeded. Set to `0` to restore per-record emission (tests/debugging). | `250` |
89
+ | `OM_PROGRESS_BROADCAST_MIN_INTERVAL_MS` | Coalesces intermediate `progress.job.updated` broadcasts per job: the service emits only when this many ms elapsed since the last broadcast **or** `progressPercent` advanced by ≥1; sub-threshold updates buffer in memory. Keeps bulk workers from firing one serialized `pg_notify` roundtrip + tenant-wide SSE fan-out per record. Persistence is throttled independently: heartbeats reach the database at least every `HEARTBEAT_INTERVAL_MS` (5s) regardless of this knob, so no value can starve the 60s `STALE_JOB_TIMEOUT_SECONDS` sweep. Terminal events (`created`/`started`/`completed`/`failed`/`cancelled`) are never throttled. Set to `0` to restore per-record emission (tests/debugging). | `250` |
90
+
91
+ ## Concurrency Semantics (multi-instance safe)
92
+
93
+ `progressService` is safe to run across many app/worker instances. Do not reintroduce read-modify-write transitions:
94
+
95
+ - **Every status transition is a status-guarded `nativeUpdate` (CAS).** An update that matches zero rows lost the race — it MUST NOT emit events or overwrite the row. Allowed transitions: start from `pending|failed` (an already-running start is an idempotent no-op; `failed` allows queue retries and recovery from a wrong stale sweep), complete from `pending|running|failed`, fail from `pending|running`, cancel from `pending|running|failed`.
96
+ - **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it.
97
+ - **`incrementProgress` deltas persist as atomic SQL increments** (`processed_count + n`), and the service reloads the database winner before returning or emitting, so concurrent writers never lose or report stale counts.
98
+ - **Update-path reads use `disableIdentityMap: true`** — `isCancellationRequested` and lifecycle reads must always see fresh cross-process state, never a stale managed entity.
99
+ - **The stale sweep (`markStaleJobsFailed`) re-checks staleness per row inside the CAS**, so concurrent sweepers emit exactly one `JOB_FAILED` per job, and it also fails `pending` jobs that never started within `STALE_PENDING_TIMEOUT_SECONDS` (a late queue delivery recovers them via `startJob`'s `failed → running` transition).
90
100
 
91
101
  ## Cross-References
92
102
 
@@ -25,6 +25,7 @@ export interface ProgressService {
25
25
 
26
26
  export const HEARTBEAT_INTERVAL_MS = 5000
27
27
  export const STALE_JOB_TIMEOUT_SECONDS = 60
28
+ export const STALE_PENDING_TIMEOUT_SECONDS = 900
28
29
 
29
30
  export function calculateEta(
30
31
  processedCount: number,