@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/resources_resource/index.js +2 -0
- package/dist/generated/entities/resources_resource/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/salesFixtures.js +31 -2
- package/dist/helpers/integration/salesFixtures.js.map +2 -2
- package/dist/helpers/integration/salesUi.js +5 -0
- package/dist/helpers/integration/salesUi.js.map +2 -2
- 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/communication_channels/data/enrichers.js +4 -4
- package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
- package/dist/modules/customers/analytics.js +1 -0
- package/dist/modules/customers/analytics.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +2 -1
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/DealsSection.js +1 -1
- package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +52 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +42 -26
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.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/dictionaries/api/[dictionaryId]/entries/route.js +34 -12
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/route.js.map +2 -2
- package/dist/modules/dictionaries/api/openapi.js +15 -2
- package/dist/modules/dictionaries/api/openapi.js.map +2 -2
- package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js +30 -29
- package/dist/modules/dictionaries/components/hooks/useDictionaryEntries.js.map +2 -2
- package/dist/modules/dictionaries/data/validators.js +7 -0
- package/dist/modules/dictionaries/data/validators.js.map +2 -2
- package/dist/modules/dictionaries/lib/clientEntries.js +4 -3
- package/dist/modules/dictionaries/lib/clientEntries.js.map +2 -2
- package/dist/modules/dictionaries/lib/fetchAllEntries.js +43 -0
- package/dist/modules/dictionaries/lib/fetchAllEntries.js.map +7 -0
- package/dist/modules/entities/api/definitions.js +51 -3
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/entities.js +21 -2
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +3 -24
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/api/sidebar-entities.js +34 -12
- package/dist/modules/entities/api/sidebar-entities.js.map +2 -2
- package/dist/modules/entities/lib/entityAcl.js +46 -0
- package/dist/modules/entities/lib/entityAcl.js.map +2 -2
- package/dist/modules/messages/api/route.js +2 -5
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/lib/participantScope.js +15 -0
- package/dist/modules/messages/lib/participantScope.js.map +7 -0
- 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/dist/modules/planner/components/unavailabilityReasons.js +3 -3
- package/dist/modules/planner/components/unavailabilityReasons.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js +2 -0
- package/dist/modules/progress/lib/progressService.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +322 -104
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/resources/api/resources.js +2 -0
- package/dist/modules/resources/api/resources.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +3 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/resources/commands/resources.js +9 -0
- package/dist/modules/resources/commands/resources.js.map +2 -2
- package/dist/modules/resources/components/detail/dictionaries.js +3 -3
- package/dist/modules/resources/components/detail/dictionaries.js.map +2 -2
- package/dist/modules/resources/data/entities.js +3 -0
- package/dist/modules/resources/data/entities.js.map +2 -2
- package/dist/modules/resources/data/validators.js +6 -2
- package/dist/modules/resources/data/validators.js.map +2 -2
- package/dist/modules/resources/lib/seeds.js +16 -9
- package/dist/modules/resources/lib/seeds.js.map +2 -2
- package/dist/modules/resources/migrations/Migration20260608231000.js +13 -0
- package/dist/modules/resources/migrations/Migration20260608231000.js.map +7 -0
- package/dist/modules/sales/backend/sales/documents/create/page.js +11 -14
- package/dist/modules/sales/backend/sales/documents/create/page.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +8 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +18 -9
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +12 -5
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +48 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +167 -0
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +7 -0
- package/dist/modules/sales/data/validators.js +5 -1
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/staff/components/detail/dictionaries.js +3 -3
- package/dist/modules/staff/components/detail/dictionaries.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +11 -4
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/resources_resource/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/salesFixtures.ts +34 -2
- package/src/helpers/integration/salesUi.ts +8 -0
- 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/communication_channels/data/enrichers.ts +6 -26
- package/src/modules/customers/analytics.ts +1 -0
- package/src/modules/customers/components/detail/DealForm.tsx +5 -1
- package/src/modules/customers/components/detail/DealsSection.tsx +1 -1
- package/src/modules/customers/data/validators.ts +5 -1
- package/src/modules/dashboards/i18n/de.json +3 -0
- package/src/modules/dashboards/i18n/en.json +3 -0
- package/src/modules/dashboards/i18n/es.json +3 -0
- package/src/modules/dashboards/i18n/pl.json +3 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +86 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +33 -18
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +2 -2
- 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/dictionaries/api/[dictionaryId]/entries/route.ts +39 -11
- package/src/modules/dictionaries/api/openapi.ts +19 -0
- package/src/modules/dictionaries/components/hooks/useDictionaryEntries.ts +35 -36
- package/src/modules/dictionaries/data/validators.ts +14 -0
- package/src/modules/dictionaries/lib/clientEntries.ts +4 -3
- package/src/modules/dictionaries/lib/fetchAllEntries.ts +72 -0
- package/src/modules/entities/api/definitions.ts +51 -3
- package/src/modules/entities/api/entities.ts +30 -2
- package/src/modules/entities/api/records.ts +3 -27
- package/src/modules/entities/api/sidebar-entities.ts +43 -14
- package/src/modules/entities/lib/entityAcl.ts +58 -0
- package/src/modules/messages/api/route.ts +4 -5
- package/src/modules/messages/lib/participantScope.ts +64 -0
- 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
- package/src/modules/planner/components/unavailabilityReasons.ts +3 -3
- package/src/modules/progress/AGENTS.md +11 -1
- package/src/modules/progress/lib/progressService.ts +1 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +385 -117
- package/src/modules/resources/api/resources.ts +2 -0
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +9 -3
- package/src/modules/resources/commands/resources.ts +10 -0
- package/src/modules/resources/components/detail/dictionaries.ts +3 -3
- package/src/modules/resources/data/entities.ts +3 -0
- package/src/modules/resources/data/validators.ts +5 -0
- package/src/modules/resources/lib/seeds.ts +16 -9
- package/src/modules/resources/migrations/.snapshot-open-mercato.json +17 -1
- package/src/modules/resources/migrations/Migration20260608231000.ts +13 -0
- package/src/modules/sales/backend/sales/documents/create/page.tsx +2 -4
- package/src/modules/sales/commands/documents.ts +8 -0
- package/src/modules/sales/components/documents/ItemsSection.tsx +19 -10
- package/src/modules/sales/components/documents/LineItemDialog.tsx +13 -5
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +48 -6
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +198 -0
- package/src/modules/sales/data/validators.ts +7 -1
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/staff/components/detail/dictionaries.ts +3 -3
- 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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
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,
|
|
@@ -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
|
|
49
|
+
const entriesCall = await fetchAllDictionaryEntries(resolved.id)
|
|
49
50
|
if (!entriesCall.ok) return []
|
|
50
|
-
|
|
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`
|
|
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
|
|