@open-mercato/core 0.6.8-develop.7013.1.48f7adca80 → 0.6.8-develop.7016.1.4ed7b1e49d

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 (89) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +1 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/api_keys/di.js +9 -0
  5. package/dist/modules/api_keys/di.js.map +7 -0
  6. package/dist/modules/api_keys/services/principalService.js +31 -0
  7. package/dist/modules/api_keys/services/principalService.js.map +7 -0
  8. package/dist/modules/attachments/di.js +11 -1
  9. package/dist/modules/attachments/di.js.map +2 -2
  10. package/dist/modules/attachments/index.js.map +1 -1
  11. package/dist/modules/attachments/lib/attachment-service.js +313 -0
  12. package/dist/modules/attachments/lib/attachment-service.js.map +7 -0
  13. package/dist/modules/attachments/lib/scoped-upload-service.js +15 -2
  14. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  15. package/dist/modules/attachments/lib/upload-limits.js +6 -2
  16. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  17. package/dist/modules/auth/data/entities.js.map +2 -2
  18. package/dist/modules/auth/di.js +35 -9
  19. package/dist/modules/auth/di.js.map +2 -2
  20. package/dist/modules/auth/services/principalService.js +200 -0
  21. package/dist/modules/auth/services/principalService.js.map +7 -0
  22. package/dist/modules/auth/services/rbacService.js +67 -24
  23. package/dist/modules/auth/services/rbacService.js.map +3 -3
  24. package/dist/modules/auth/services/roleOrganizationScope.js +41 -0
  25. package/dist/modules/auth/services/roleOrganizationScope.js.map +7 -0
  26. package/dist/modules/customers/api/companies/[id]/people/route.js +19 -63
  27. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  28. package/dist/modules/customers/api/companies/[id]/route.js +9 -85
  29. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  30. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +4 -3
  31. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
  32. package/dist/modules/customers/commands/personCompanyLinks.js +131 -2
  33. package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
  34. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -1
  35. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  36. package/dist/modules/customers/components/detail/PersonCompaniesSection.js +3 -1
  37. package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
  38. package/dist/modules/customers/data/validators.js +10 -2
  39. package/dist/modules/customers/data/validators.js.map +2 -2
  40. package/dist/modules/customers/events.js +5 -0
  41. package/dist/modules/customers/events.js.map +2 -2
  42. package/dist/modules/customers/lib/personCompanies.js +100 -2
  43. package/dist/modules/customers/lib/personCompanies.js.map +2 -2
  44. package/dist/modules/directory/di.js +9 -0
  45. package/dist/modules/directory/di.js.map +2 -2
  46. package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
  47. package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
  48. package/dist/modules/directory/services/organizationScopeService.js +56 -0
  49. package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
  50. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  51. package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
  52. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  53. package/dist/modules/workflows/lib/activity-executor.js +4 -0
  54. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  55. package/package.json +7 -7
  56. package/src/modules/api_keys/api/keys/route.ts +3 -1
  57. package/src/modules/api_keys/di.ts +7 -0
  58. package/src/modules/api_keys/services/principalService.ts +33 -0
  59. package/src/modules/attachments/AGENTS.md +15 -0
  60. package/src/modules/attachments/di.ts +16 -0
  61. package/src/modules/attachments/i18n/de.json +5 -0
  62. package/src/modules/attachments/i18n/en.json +5 -0
  63. package/src/modules/attachments/i18n/es.json +5 -0
  64. package/src/modules/attachments/i18n/ko.json +5 -0
  65. package/src/modules/attachments/i18n/pl.json +5 -0
  66. package/src/modules/attachments/index.ts +10 -0
  67. package/src/modules/attachments/lib/attachment-service.ts +468 -0
  68. package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
  69. package/src/modules/attachments/lib/upload-limits.ts +6 -2
  70. package/src/modules/auth/data/entities.ts +4 -1
  71. package/src/modules/auth/di.ts +46 -16
  72. package/src/modules/auth/services/principalService.ts +266 -0
  73. package/src/modules/auth/services/rbacService.ts +110 -24
  74. package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
  75. package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
  76. package/src/modules/customers/api/companies/[id]/route.ts +11 -97
  77. package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
  78. package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
  79. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
  80. package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
  81. package/src/modules/customers/data/validators.ts +18 -3
  82. package/src/modules/customers/events.ts +5 -0
  83. package/src/modules/customers/lib/personCompanies.ts +136 -1
  84. package/src/modules/directory/di.ts +14 -2
  85. package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
  86. package/src/modules/directory/services/organizationScopeService.ts +85 -0
  87. package/src/modules/directory/utils/organizationScope.ts +4 -15
  88. package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
  89. package/src/modules/workflows/lib/activity-executor.ts +9 -0
@@ -56,6 +56,136 @@ async function requireCompany(
56
56
  return company
57
57
  }
58
58
 
59
+ export type CompanyPersonUnionEntry = {
60
+ entity: CustomerEntity
61
+ profile: CustomerPersonProfile | null
62
+ linkedAt: string | null
63
+ }
64
+
65
+ /**
66
+ * Resolve "people belonging to this company" as the union of active
67
+ * `CustomerPersonCompanyLink` rows and `CustomerPersonProfile.company`
68
+ * profile-only assignments (data that never got a link row, e.g. from
69
+ * migrated CRM imports). This is the single source of truth for both the
70
+ * company People tab list and its badge count — see #5114.
71
+ */
72
+ export async function loadCompanyPeopleUnion(
73
+ em: EntityManager,
74
+ company: CustomerEntity,
75
+ scope: { tenantId?: string | null; organizationId?: string | null },
76
+ ): Promise<CompanyPersonUnionEntry[]> {
77
+ const decryptionScope = {
78
+ tenantId: scope.tenantId ?? company.tenantId ?? null,
79
+ organizationId: scope.organizationId ?? company.organizationId ?? null,
80
+ }
81
+ const relatedPeopleById = new Map<string, CompanyPersonUnionEntry>()
82
+
83
+ const companyLinkWhere = await withActiveCustomerPersonCompanyLinkFilter(
84
+ em,
85
+ { company: company.id, organizationId: company.organizationId, tenantId: company.tenantId },
86
+ 'customers.companies.loadCompanyPeopleUnion',
87
+ )
88
+ const companyLinks = filterActivePersonCompanyLinks(
89
+ await findWithDecryption(
90
+ em,
91
+ CustomerPersonCompanyLink,
92
+ companyLinkWhere,
93
+ { populate: ['person', 'person.personProfile'], orderBy: { isPrimary: 'desc', createdAt: 'asc' } },
94
+ decryptionScope,
95
+ ),
96
+ )
97
+ companyLinks.forEach((link) => {
98
+ const entity = typeof link.person === 'string' ? null : link.person
99
+ if (!entity || entity.kind !== 'person' || entity.deletedAt) return
100
+ const personProfile =
101
+ entity.personProfile && typeof entity.personProfile !== 'string' ? entity.personProfile : null
102
+ relatedPeopleById.set(entity.id, {
103
+ entity,
104
+ profile: personProfile,
105
+ linkedAt: link.createdAt instanceof Date ? link.createdAt.toISOString() : null,
106
+ })
107
+ })
108
+
109
+ const profiles = await findWithDecryption(
110
+ em,
111
+ CustomerPersonProfile,
112
+ {
113
+ company: company.id,
114
+ tenantId: company.tenantId,
115
+ organizationId: company.organizationId,
116
+ entity: { deletedAt: null },
117
+ },
118
+ { populate: ['entity'] },
119
+ decryptionScope,
120
+ )
121
+ profiles.forEach((entry) => {
122
+ const entity = entry.entity as CustomerEntity | null
123
+ if (!entity || entity.kind !== 'person' || entity.deletedAt) return
124
+ if (!relatedPeopleById.has(entity.id)) {
125
+ relatedPeopleById.set(entity.id, {
126
+ entity,
127
+ profile: entry ?? null,
128
+ linkedAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : null,
129
+ })
130
+ }
131
+ })
132
+
133
+ return Array.from(relatedPeopleById.values())
134
+ }
135
+
136
+ /**
137
+ * Count-only sibling of `loadCompanyPeopleUnion` for the company People badge, which
138
+ * needs the size of the union and nothing else. Both queries project just the foreign
139
+ * key column and push the `kind: 'person'` / `deletedAt: null` predicates into the
140
+ * WHERE clause, so no person row is hydrated and no PII is decrypted to produce a
141
+ * number — `findWithDecryption` is deliberately not used here because the projections
142
+ * select no encrypted column.
143
+ */
144
+ export async function countCompanyPeopleUnion(
145
+ em: EntityManager,
146
+ company: CustomerEntity,
147
+ ): Promise<number> {
148
+ const personIds = new Set<string>()
149
+
150
+ const companyLinkWhere = await withActiveCustomerPersonCompanyLinkFilter(
151
+ em,
152
+ {
153
+ company: company.id,
154
+ organizationId: company.organizationId,
155
+ tenantId: company.tenantId,
156
+ person: { kind: 'person', deletedAt: null },
157
+ },
158
+ 'customers.companies.countCompanyPeopleUnion',
159
+ )
160
+ const companyLinks = filterActivePersonCompanyLinks(
161
+ await em.find(CustomerPersonCompanyLink, companyLinkWhere as any, {
162
+ fields: ['person', 'deletedAt'],
163
+ } as any),
164
+ )
165
+ companyLinks.forEach((link) => {
166
+ const personId = typeof link.person === 'string' ? link.person : link.person?.id
167
+ if (personId) personIds.add(personId)
168
+ })
169
+
170
+ const profiles = await em.find(
171
+ CustomerPersonProfile,
172
+ {
173
+ company: company.id,
174
+ tenantId: company.tenantId,
175
+ organizationId: company.organizationId,
176
+ entity: { kind: 'person', deletedAt: null },
177
+ } as any,
178
+ { fields: ['entity'] } as any,
179
+ )
180
+ profiles.forEach((entry) => {
181
+ const entity = entry.entity as CustomerEntity | string | null
182
+ const entityId = typeof entity === 'string' ? entity : entity?.id
183
+ if (entityId) personIds.add(entityId)
184
+ })
185
+
186
+ return personIds.size
187
+ }
188
+
59
189
  export async function loadPersonCompanyLinks(
60
190
  em: EntityManager,
61
191
  person: CustomerEntity,
@@ -298,7 +428,12 @@ export async function removePersonCompanyLink(
298
428
 
299
429
  if (!link) {
300
430
  if (profile.company && typeof profile.company !== 'string' && profile.company.id === linkId) {
301
- profile.company = null
431
+ // Profile-only assignment: no link row backs it, so only the legacy field is
432
+ // cleared. It must keep mirroring the primary link the way every other detach
433
+ // path leaves it, so promote a remaining primary link instead of nulling blindly.
434
+ const primary = existingLinks.find((entry) => entry.isPrimary) ?? null
435
+ const primaryCompany = primary ? resolveLinkedCompany(primary) : null
436
+ profile.company = primaryCompany ?? null
302
437
  return
303
438
  }
304
439
  throw notFound('Company link not found')
@@ -1,6 +1,18 @@
1
+ import { asClass, asFunction } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
1
3
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { DefaultOrganizationScopeService } from './services/organizationScopeService'
5
+ import { DefaultOrganizationHierarchyService } from './services/organizationHierarchyService'
6
+
7
+ type OrganizationScopeRbac = ConstructorParameters<typeof DefaultOrganizationScopeService>[1]
2
8
 
3
9
  export function register(container: AppContainer) {
4
- // Directory module has no services yet; placeholder for future overrides
10
+ container.register({
11
+ organizationHierarchyService: asClass(DefaultOrganizationHierarchyService).scoped(),
12
+ organizationScopeService: asFunction((cradle: { em: EntityManager; rbacService: OrganizationScopeRbac }) =>
13
+ new DefaultOrganizationScopeService(cradle.em, cradle.rbacService, container),
14
+ )
15
+ .scoped()
16
+ .proxy(),
17
+ })
5
18
  }
6
-
@@ -0,0 +1,34 @@
1
+ import type { FilterQuery } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Organization } from '../data/entities'
6
+
7
+ function normalizeIds(values: unknown): string[] {
8
+ if (!Array.isArray(values)) return []
9
+ return Array.from(new Set(values
10
+ .map((value) => typeof value === 'string' ? value.trim() : '')
11
+ .filter(Boolean)))
12
+ }
13
+
14
+ export class DefaultOrganizationHierarchyService implements OrganizationHierarchyService {
15
+ constructor(private readonly em: EntityManager) {}
16
+
17
+ async resolveAncestorIds(input: {
18
+ tenantId: string
19
+ organizationId: string
20
+ }): Promise<string[] | null> {
21
+ const organization = await findOneWithDecryption(
22
+ this.em,
23
+ Organization,
24
+ {
25
+ id: input.organizationId,
26
+ tenant: input.tenantId,
27
+ deletedAt: null,
28
+ } as FilterQuery<Organization>,
29
+ { fields: ['id', 'ancestorIds'] },
30
+ { tenantId: input.tenantId, organizationId: input.organizationId },
31
+ )
32
+ return organization ? normalizeIds(organization.ancestorIds) : null
33
+ }
34
+ }
@@ -0,0 +1,85 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import type {
4
+ OrganizationScopeService,
5
+ OrganizationScopeRequest,
6
+ } from '@open-mercato/shared/lib/auth/principal-service'
7
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
8
+ import { resolveOrganizationScope, resolveOrganizationScopeForRequest } from '../utils/organizationScope'
9
+
10
+ type OrganizationScopeRbac = {
11
+ invalidateUserCache(userId: string): Promise<void>
12
+ loadAcl(
13
+ userId: string,
14
+ scope: { tenantId: string | null; organizationId: string | null },
15
+ ): Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
16
+ }
17
+
18
+ export class DefaultOrganizationScopeService implements OrganizationScopeService {
19
+ constructor(
20
+ private readonly em: EntityManager,
21
+ private readonly rbac: OrganizationScopeRbac,
22
+ private readonly container: AwilixContainer,
23
+ ) {}
24
+
25
+ resolve(input: {
26
+ auth: AuthContext | null | undefined
27
+ selectedId?: string | null
28
+ tenantId?: string | null
29
+ freshAcl?: boolean
30
+ }) {
31
+ const auth = input.auth
32
+ if (input.freshAcl && auth?.sub) {
33
+ return this.resolveFresh({
34
+ auth,
35
+ selectedId: input.selectedId,
36
+ tenantId: input.tenantId,
37
+ }).then((result) => result.scope)
38
+ }
39
+ return resolveOrganizationScope({
40
+ em: this.em,
41
+ rbac: this.rbac as Parameters<typeof resolveOrganizationScope>[0]['rbac'],
42
+ auth: input.auth,
43
+ selectedId: input.selectedId,
44
+ tenantId: input.tenantId,
45
+ })
46
+ }
47
+
48
+ async resolveFresh(input: {
49
+ auth: NonNullable<AuthContext>
50
+ selectedId?: string | null
51
+ tenantId?: string | null
52
+ }) {
53
+ await this.rbac.invalidateUserCache(input.auth.sub)
54
+ const acl = await this.rbac.loadAcl(input.auth.sub, {
55
+ tenantId: input.tenantId ?? input.auth.tenantId ?? null,
56
+ organizationId: input.selectedId ?? input.auth.orgId ?? null,
57
+ })
58
+ const freshRbac: Parameters<typeof resolveOrganizationScope>[0]['rbac'] = {
59
+ loadAcl: async () => acl,
60
+ }
61
+ const scope = await resolveOrganizationScope({
62
+ em: this.em,
63
+ rbac: freshRbac,
64
+ auth: input.auth,
65
+ selectedId: input.selectedId,
66
+ tenantId: input.tenantId,
67
+ })
68
+ return { scope, acl }
69
+ }
70
+
71
+ resolveForRequest(input: {
72
+ auth: AuthContext | null | undefined
73
+ request?: OrganizationScopeRequest
74
+ selectedId?: string | null
75
+ tenantId?: string | null
76
+ }) {
77
+ return resolveOrganizationScopeForRequest({
78
+ container: this.container,
79
+ auth: input.auth,
80
+ request: input.request,
81
+ selectedId: input.selectedId,
82
+ tenantId: input.tenantId,
83
+ })
84
+ }
85
+ }
@@ -5,6 +5,7 @@ import { Organization } from '@open-mercato/core/modules/directory/data/entities
5
5
  import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
8
+ import type { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'
8
9
  import { getCurrentCacheTenant, runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
9
10
  import { parseSelectedOrganizationCookie, parseSelectedTenantCookie } from './scopeCookies'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -13,19 +14,7 @@ const logger = createLogger('directory').child({ component: 'org-scope-cache' })
13
14
 
14
15
  export { parseSelectedOrganizationCookie, parseSelectedTenantCookie }
15
16
 
16
- export type OrganizationScope = {
17
- selectedId: string | null
18
- filterIds: string[] | null
19
- allowedIds: string[] | null
20
- tenantId: string | null
21
- // True when the caller explicitly selected a concrete organization (cookie /
22
- // selectedId param) that could not be honored — it does not exist for the
23
- // tenant or is not accessible. Reads degrade gracefully (filterIds/selectedId
24
- // fall back to the caller's accessible orgs), but writes MUST fail loudly on
25
- // this so a record never lands under an org the caller did not intend. Absent
26
- // (undefined) means the selection — if any — was honored.
27
- selectionRejected?: boolean
28
- }
17
+ export type { OrganizationScope }
29
18
 
30
19
  // Phase 4 — short-TTL cache for resolveOrganizationScopeForRequest.
31
20
  // OrganizationScope is a pure function of (userId, tenantId, selectedOrgId,
@@ -249,8 +238,8 @@ export async function resolveOrganizationScope({
249
238
  tenantId: tenantIdOverride,
250
239
  }: {
251
240
  em: EntityManager
252
- rbac: RbacService
253
- auth: AuthContext
241
+ rbac: Pick<RbacService, 'loadAcl'>
242
+ auth: AuthContext | null | undefined
254
243
  selectedId?: string | null
255
244
  tenantId?: string | null
256
245
  }): Promise<OrganizationScope> {
@@ -19,7 +19,7 @@ import { readAttachmentMetadata } from '@open-mercato/core/modules/attachments/l
19
19
  import { clearAttachmentThumbnailCache } from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
20
20
  import { isMultipartRequestWithinUploadLimit } from '@open-mercato/core/modules/attachments/lib/upload-limits'
21
21
  import {
22
- ScopedAttachmentUploadError,
22
+ isScopedAttachmentUploadError,
23
23
  type ScopedAttachmentUploadErrorCode,
24
24
  } from '@open-mercato/core/modules/attachments/lib/scoped-upload-service'
25
25
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -413,7 +413,7 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
413
413
  tags: [CUSTOMER_VISIBLE_ATTACHMENT_TAG],
414
414
  })
415
415
  } catch (error) {
416
- if (error instanceof ScopedAttachmentUploadError) {
416
+ if (isScopedAttachmentUploadError(error)) {
417
417
  const { t } = await resolveTranslations()
418
418
  const [key, fallback] = ATTACHMENT_ERROR_TRANSLATIONS[error.code]
419
419
  ?? ['warranty_claims.portal.detail.attachmentError', 'Attachment upload failed.']
@@ -22,6 +22,7 @@ import {
22
22
  type HostLookup,
23
23
  } from '@open-mercato/shared/lib/url-safety'
24
24
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
25
+ import { isPrivateCrossProcessBroadcastEvent } from '@open-mercato/shared/modules/events'
25
26
  import { callWebhookConfigSchema } from '../data/validators'
26
27
  import { WorkflowActivityJob, WORKFLOW_ACTIVITIES_QUEUE_NAME } from './activity-queue-types'
27
28
  import { logWorkflowEvent } from './event-logger'
@@ -578,6 +579,14 @@ export async function executeEmitEvent(
578
579
  throw new Error('EMIT_EVENT requires "eventName" field')
579
580
  }
580
581
 
582
+ // Workflow definitions are tenant-managed input. Private cross-process
583
+ // events coordinate trusted server state (for example closing an in-memory
584
+ // collaboration room), so allowing a workflow to name one would turn a
585
+ // regular event activity into a cross-process invalidation primitive.
586
+ if (isPrivateCrossProcessBroadcastEvent(eventName)) {
587
+ throw new Error(`EMIT_EVENT cannot emit private cross-process event "${eventName}"`)
588
+ }
589
+
581
590
  // Emissions are fire-and-forget and no subscriber validates the payload shape,
582
591
  // so an unresolved `{{context.x}}` here is even quieter than on the command
583
592
  // path — it ships the literal template to every consumer. Fail loudly instead.