@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +1 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/api_keys/di.js +9 -0
- package/dist/modules/api_keys/di.js.map +7 -0
- package/dist/modules/api_keys/services/principalService.js +31 -0
- package/dist/modules/api_keys/services/principalService.js.map +7 -0
- package/dist/modules/attachments/di.js +11 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/index.js.map +1 -1
- package/dist/modules/attachments/lib/attachment-service.js +313 -0
- package/dist/modules/attachments/lib/attachment-service.js.map +7 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js +15 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/upload-limits.js +6 -2
- package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/di.js +35 -9
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/services/principalService.js +200 -0
- package/dist/modules/auth/services/principalService.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +67 -24
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/auth/services/roleOrganizationScope.js +41 -0
- package/dist/modules/auth/services/roleOrganizationScope.js.map +7 -0
- package/dist/modules/customers/api/companies/[id]/people/route.js +19 -63
- package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +9 -85
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +4 -3
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
- package/dist/modules/customers/commands/personCompanyLinks.js +131 -2
- package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -1
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonCompaniesSection.js +3 -1
- package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
- package/dist/modules/customers/data/validators.js +10 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/events.js +5 -0
- package/dist/modules/customers/events.js.map +2 -2
- package/dist/modules/customers/lib/personCompanies.js +100 -2
- package/dist/modules/customers/lib/personCompanies.js.map +2 -2
- package/dist/modules/directory/di.js +9 -0
- package/dist/modules/directory/di.js.map +2 -2
- package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
- package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
- package/dist/modules/directory/services/organizationScopeService.js +56 -0
- package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +4 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +3 -1
- package/src/modules/api_keys/di.ts +7 -0
- package/src/modules/api_keys/services/principalService.ts +33 -0
- package/src/modules/attachments/AGENTS.md +15 -0
- package/src/modules/attachments/di.ts +16 -0
- package/src/modules/attachments/i18n/de.json +5 -0
- package/src/modules/attachments/i18n/en.json +5 -0
- package/src/modules/attachments/i18n/es.json +5 -0
- package/src/modules/attachments/i18n/ko.json +5 -0
- package/src/modules/attachments/i18n/pl.json +5 -0
- package/src/modules/attachments/index.ts +10 -0
- package/src/modules/attachments/lib/attachment-service.ts +468 -0
- package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
- package/src/modules/attachments/lib/upload-limits.ts +6 -2
- package/src/modules/auth/data/entities.ts +4 -1
- package/src/modules/auth/di.ts +46 -16
- package/src/modules/auth/services/principalService.ts +266 -0
- package/src/modules/auth/services/rbacService.ts +110 -24
- package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
- package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
- package/src/modules/customers/api/companies/[id]/route.ts +11 -97
- package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
- package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
- package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
- package/src/modules/customers/data/validators.ts +18 -3
- package/src/modules/customers/events.ts +5 -0
- package/src/modules/customers/lib/personCompanies.ts +136 -1
- package/src/modules/directory/di.ts +14 -2
- package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
- package/src/modules/directory/services/organizationScopeService.ts +85 -0
- package/src/modules/directory/utils/organizationScope.ts +4 -15
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|