@open-mercato/core 0.6.6-develop.6366.1.fba69a7362 → 0.6.6-develop.6370.1.efeac59f8b
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/customers/api/companies/[id]/route.js +80 -2
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +118 -24
- package/dist/modules/customers/api/deals/[id]/route.js.map +3 -3
- package/package.json +7 -7
- package/src/modules/customer_accounts/i18n/de.json +72 -72
- package/src/modules/customers/api/companies/[id]/route.ts +109 -2
- package/src/modules/customers/api/deals/[id]/route.ts +169 -25
|
@@ -103,88 +103,88 @@
|
|
|
103
103
|
"customer_accounts.admin.flash.deactivated": "User deactivated",
|
|
104
104
|
"customer_accounts.admin.flash.deleted": "User deleted",
|
|
105
105
|
"customer_accounts.admin.inactive": "Inactive",
|
|
106
|
-
"customer_accounts.admin.portalFeatures.addresses.manage": "
|
|
107
|
-
"customer_accounts.admin.portalFeatures.addresses.view": "
|
|
108
|
-
"customer_accounts.admin.portalFeatures.groups.addresses": "
|
|
109
|
-
"customer_accounts.admin.portalFeatures.groups.invoices": "
|
|
110
|
-
"customer_accounts.admin.portalFeatures.groups.orders": "
|
|
111
|
-
"customer_accounts.admin.portalFeatures.groups.profile": "
|
|
112
|
-
"customer_accounts.admin.portalFeatures.groups.quotes": "
|
|
113
|
-
"customer_accounts.admin.portalFeatures.groups.users": "
|
|
114
|
-
"customer_accounts.admin.portalFeatures.invoices.view": "
|
|
115
|
-
"customer_accounts.admin.portalFeatures.orders.create": "
|
|
116
|
-
"customer_accounts.admin.portalFeatures.orders.view": "
|
|
117
|
-
"customer_accounts.admin.portalFeatures.profile.edit": "
|
|
118
|
-
"customer_accounts.admin.portalFeatures.profile.view": "
|
|
119
|
-
"customer_accounts.admin.portalFeatures.quotes.request": "
|
|
120
|
-
"customer_accounts.admin.portalFeatures.quotes.view": "
|
|
121
|
-
"customer_accounts.admin.portalFeatures.users.invite": "
|
|
122
|
-
"customer_accounts.admin.portalFeatures.users.manage": "
|
|
123
|
-
"customer_accounts.admin.portalFeatures.users.view": "
|
|
106
|
+
"customer_accounts.admin.portalFeatures.addresses.manage": "Adressen verwalten",
|
|
107
|
+
"customer_accounts.admin.portalFeatures.addresses.view": "Adressen anzeigen",
|
|
108
|
+
"customer_accounts.admin.portalFeatures.groups.addresses": "Adressen",
|
|
109
|
+
"customer_accounts.admin.portalFeatures.groups.invoices": "Rechnungen",
|
|
110
|
+
"customer_accounts.admin.portalFeatures.groups.orders": "Bestellungen",
|
|
111
|
+
"customer_accounts.admin.portalFeatures.groups.profile": "Profil",
|
|
112
|
+
"customer_accounts.admin.portalFeatures.groups.quotes": "Angebote",
|
|
113
|
+
"customer_accounts.admin.portalFeatures.groups.users": "Benutzer",
|
|
114
|
+
"customer_accounts.admin.portalFeatures.invoices.view": "Rechnungen anzeigen",
|
|
115
|
+
"customer_accounts.admin.portalFeatures.orders.create": "Bestellungen erstellen",
|
|
116
|
+
"customer_accounts.admin.portalFeatures.orders.view": "Bestellungen anzeigen",
|
|
117
|
+
"customer_accounts.admin.portalFeatures.profile.edit": "Profil bearbeiten",
|
|
118
|
+
"customer_accounts.admin.portalFeatures.profile.view": "Profil anzeigen",
|
|
119
|
+
"customer_accounts.admin.portalFeatures.quotes.request": "Angebote anfordern",
|
|
120
|
+
"customer_accounts.admin.portalFeatures.quotes.view": "Angebote anzeigen",
|
|
121
|
+
"customer_accounts.admin.portalFeatures.users.invite": "Teammitglieder einladen",
|
|
122
|
+
"customer_accounts.admin.portalFeatures.users.manage": "Teammitglieder verwalten",
|
|
123
|
+
"customer_accounts.admin.portalFeatures.users.view": "Teammitglieder anzeigen",
|
|
124
124
|
"customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
|
|
125
125
|
"customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
|
|
126
126
|
"customer_accounts.admin.portalInfo.open": "Open Portal",
|
|
127
127
|
"customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
|
|
128
128
|
"customer_accounts.admin.portalInfo.title": "Customer Portal",
|
|
129
129
|
"customer_accounts.admin.portalInfo.url": "Portal URL: {url}",
|
|
130
|
-
"customer_accounts.admin.roleCreate.actions.cancel": "
|
|
131
|
-
"customer_accounts.admin.roleCreate.actions.create": "
|
|
132
|
-
"customer_accounts.admin.roleCreate.actions.saving": "
|
|
133
|
-
"customer_accounts.admin.roleCreate.error.nameRequired": "Name
|
|
134
|
-
"customer_accounts.admin.roleCreate.error.required": "Name
|
|
135
|
-
"customer_accounts.admin.roleCreate.error.save": "
|
|
136
|
-
"customer_accounts.admin.roleCreate.error.slugFormat": "Slug
|
|
137
|
-
"customer_accounts.admin.roleCreate.error.slugRequired": "Slug
|
|
138
|
-
"customer_accounts.admin.roleCreate.fields.customerAssignable": "
|
|
139
|
-
"customer_accounts.admin.roleCreate.fields.description": "
|
|
140
|
-
"customer_accounts.admin.roleCreate.fields.isDefault": "
|
|
130
|
+
"customer_accounts.admin.roleCreate.actions.cancel": "Abbrechen",
|
|
131
|
+
"customer_accounts.admin.roleCreate.actions.create": "Rolle erstellen",
|
|
132
|
+
"customer_accounts.admin.roleCreate.actions.saving": "Erstellen...",
|
|
133
|
+
"customer_accounts.admin.roleCreate.error.nameRequired": "Name ist erforderlich",
|
|
134
|
+
"customer_accounts.admin.roleCreate.error.required": "Name und Slug sind erforderlich",
|
|
135
|
+
"customer_accounts.admin.roleCreate.error.save": "Rolle konnte nicht erstellt werden",
|
|
136
|
+
"customer_accounts.admin.roleCreate.error.slugFormat": "Slug darf nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche enthalten",
|
|
137
|
+
"customer_accounts.admin.roleCreate.error.slugRequired": "Slug ist erforderlich",
|
|
138
|
+
"customer_accounts.admin.roleCreate.fields.customerAssignable": "Kunden können sich selbst zuweisen",
|
|
139
|
+
"customer_accounts.admin.roleCreate.fields.description": "Beschreibung",
|
|
140
|
+
"customer_accounts.admin.roleCreate.fields.isDefault": "Standardrolle (wird neuen Benutzern automatisch zugewiesen)",
|
|
141
141
|
"customer_accounts.admin.roleCreate.fields.name": "Name",
|
|
142
|
-
"customer_accounts.admin.roleCreate.fields.namePlaceholder": "
|
|
142
|
+
"customer_accounts.admin.roleCreate.fields.namePlaceholder": "z. B. Käufer",
|
|
143
143
|
"customer_accounts.admin.roleCreate.fields.slug": "Slug",
|
|
144
|
-
"customer_accounts.admin.roleCreate.fields.slugHint": "
|
|
145
|
-
"customer_accounts.admin.roleCreate.fields.slugPlaceholder": "
|
|
146
|
-
"customer_accounts.admin.roleCreate.flash.created": "
|
|
147
|
-
"customer_accounts.admin.roleCreate.sections.details": "
|
|
148
|
-
"customer_accounts.admin.roleCreate.sections.options": "
|
|
149
|
-
"customer_accounts.admin.roleCreate.title": "
|
|
150
|
-
"customer_accounts.admin.roleDetail.actions.backToList": "
|
|
151
|
-
"customer_accounts.admin.roleDetail.actions.cancel": "
|
|
152
|
-
"customer_accounts.admin.roleDetail.actions.delete": "
|
|
153
|
-
"customer_accounts.admin.roleDetail.actions.save": "
|
|
154
|
-
"customer_accounts.admin.roleDetail.actions.saving": "
|
|
155
|
-
"customer_accounts.admin.roleDetail.error.load": "
|
|
156
|
-
"customer_accounts.admin.roleDetail.error.notFound": "
|
|
157
|
-
"customer_accounts.admin.roleDetail.error.save": "
|
|
158
|
-
"customer_accounts.admin.roleDetail.error.saveAcl": "
|
|
159
|
-
"customer_accounts.admin.roleDetail.fields.customerAssignable": "
|
|
160
|
-
"customer_accounts.admin.roleDetail.fields.description": "
|
|
161
|
-
"customer_accounts.admin.roleDetail.fields.isDefault": "
|
|
144
|
+
"customer_accounts.admin.roleCreate.fields.slugHint": "Nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche.",
|
|
145
|
+
"customer_accounts.admin.roleCreate.fields.slugPlaceholder": "z. B. kaeufer",
|
|
146
|
+
"customer_accounts.admin.roleCreate.flash.created": "Rolle erstellt",
|
|
147
|
+
"customer_accounts.admin.roleCreate.sections.details": "Rollendetails",
|
|
148
|
+
"customer_accounts.admin.roleCreate.sections.options": "Optionen",
|
|
149
|
+
"customer_accounts.admin.roleCreate.title": "Kundenrolle erstellen",
|
|
150
|
+
"customer_accounts.admin.roleDetail.actions.backToList": "Zurück zu Rollen",
|
|
151
|
+
"customer_accounts.admin.roleDetail.actions.cancel": "Abbrechen",
|
|
152
|
+
"customer_accounts.admin.roleDetail.actions.delete": "Löschen",
|
|
153
|
+
"customer_accounts.admin.roleDetail.actions.save": "Änderungen speichern",
|
|
154
|
+
"customer_accounts.admin.roleDetail.actions.saving": "Speichern...",
|
|
155
|
+
"customer_accounts.admin.roleDetail.error.load": "Rolle konnte nicht geladen werden",
|
|
156
|
+
"customer_accounts.admin.roleDetail.error.notFound": "Rolle nicht gefunden",
|
|
157
|
+
"customer_accounts.admin.roleDetail.error.save": "Rolle konnte nicht gespeichert werden",
|
|
158
|
+
"customer_accounts.admin.roleDetail.error.saveAcl": "Berechtigungen konnten nicht gespeichert werden",
|
|
159
|
+
"customer_accounts.admin.roleDetail.fields.customerAssignable": "Kunden können sich selbst zuweisen",
|
|
160
|
+
"customer_accounts.admin.roleDetail.fields.description": "Beschreibung",
|
|
161
|
+
"customer_accounts.admin.roleDetail.fields.isDefault": "Standardrolle (wird neuen Benutzern automatisch zugewiesen)",
|
|
162
162
|
"customer_accounts.admin.roleDetail.fields.name": "Name",
|
|
163
|
-
"customer_accounts.admin.roleDetail.flash.saved": "
|
|
164
|
-
"customer_accounts.admin.roleDetail.loading": "
|
|
165
|
-
"customer_accounts.admin.roleDetail.sections.details": "
|
|
166
|
-
"customer_accounts.admin.roleDetail.sections.options": "
|
|
167
|
-
"customer_accounts.admin.roleDetail.sections.permissions": "Portal
|
|
168
|
-
"customer_accounts.admin.roleDetail.selectAll": "
|
|
169
|
-
"customer_accounts.admin.roles": "
|
|
170
|
-
"customer_accounts.admin.roles.actions.create": "
|
|
171
|
-
"customer_accounts.admin.roles.actions.delete": "
|
|
172
|
-
"customer_accounts.admin.roles.actions.edit": "
|
|
173
|
-
"customer_accounts.admin.roles.assignable": "
|
|
174
|
-
"customer_accounts.admin.roles.columns.customerAssignable": "
|
|
175
|
-
"customer_accounts.admin.roles.columns.description": "
|
|
176
|
-
"customer_accounts.admin.roles.columns.isDefault": "
|
|
163
|
+
"customer_accounts.admin.roleDetail.flash.saved": "Rolle aktualisiert",
|
|
164
|
+
"customer_accounts.admin.roleDetail.loading": "Rolle wird geladen...",
|
|
165
|
+
"customer_accounts.admin.roleDetail.sections.details": "Rollendetails",
|
|
166
|
+
"customer_accounts.admin.roleDetail.sections.options": "Optionen",
|
|
167
|
+
"customer_accounts.admin.roleDetail.sections.permissions": "Portal-Berechtigungen",
|
|
168
|
+
"customer_accounts.admin.roleDetail.selectAll": "Alle auswählen",
|
|
169
|
+
"customer_accounts.admin.roles": "Rollen",
|
|
170
|
+
"customer_accounts.admin.roles.actions.create": "Rolle erstellen",
|
|
171
|
+
"customer_accounts.admin.roles.actions.delete": "Löschen",
|
|
172
|
+
"customer_accounts.admin.roles.actions.edit": "Bearbeiten",
|
|
173
|
+
"customer_accounts.admin.roles.assignable": "Ja",
|
|
174
|
+
"customer_accounts.admin.roles.columns.customerAssignable": "Selbst zuweisbar",
|
|
175
|
+
"customer_accounts.admin.roles.columns.description": "Beschreibung",
|
|
176
|
+
"customer_accounts.admin.roles.columns.isDefault": "Standard",
|
|
177
177
|
"customer_accounts.admin.roles.columns.isSystem": "System",
|
|
178
178
|
"customer_accounts.admin.roles.columns.name": "Name",
|
|
179
179
|
"customer_accounts.admin.roles.columns.slug": "Slug",
|
|
180
|
-
"customer_accounts.admin.roles.confirm.delete": "
|
|
181
|
-
"customer_accounts.admin.roles.default": "
|
|
182
|
-
"customer_accounts.admin.roles.error.delete": "
|
|
183
|
-
"customer_accounts.admin.roles.error.deleteSystem": "
|
|
184
|
-
"customer_accounts.admin.roles.error.load": "
|
|
185
|
-
"customer_accounts.admin.roles.flash.deleted": "
|
|
186
|
-
"customer_accounts.admin.roles.notAssignable": "
|
|
187
|
-
"customer_accounts.admin.roles.searchPlaceholder": "
|
|
180
|
+
"customer_accounts.admin.roles.confirm.delete": "Rolle \"{{name}}\" löschen?",
|
|
181
|
+
"customer_accounts.admin.roles.default": "Standard",
|
|
182
|
+
"customer_accounts.admin.roles.error.delete": "Rolle konnte nicht gelöscht werden",
|
|
183
|
+
"customer_accounts.admin.roles.error.deleteSystem": "Systemrollen können nicht gelöscht werden",
|
|
184
|
+
"customer_accounts.admin.roles.error.load": "Rollen konnten nicht geladen werden",
|
|
185
|
+
"customer_accounts.admin.roles.flash.deleted": "Rolle gelöscht",
|
|
186
|
+
"customer_accounts.admin.roles.notAssignable": "Nein",
|
|
187
|
+
"customer_accounts.admin.roles.searchPlaceholder": "Rollen suchen...",
|
|
188
188
|
"customer_accounts.admin.roles.system": "System",
|
|
189
189
|
"customer_accounts.admin.roles.title": "Rollen",
|
|
190
190
|
"customer_accounts.admin.searchPlaceholder": "Search by name or email...",
|
|
@@ -263,8 +263,8 @@
|
|
|
263
263
|
"customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
|
|
264
264
|
"customer_accounts.domainMapping.verifyNow": "Check Now",
|
|
265
265
|
"customer_accounts.nav.group": "Customers",
|
|
266
|
-
"customer_accounts.nav.role_create": "
|
|
267
|
-
"customer_accounts.nav.role_detail": "
|
|
266
|
+
"customer_accounts.nav.role_create": "Rolle erstellen",
|
|
267
|
+
"customer_accounts.nav.role_detail": "Rollendetails",
|
|
268
268
|
"customer_accounts.nav.roles": "Kundenrollen",
|
|
269
269
|
"customer_accounts.nav.user_detail": "User Detail",
|
|
270
270
|
"customer_accounts.nav.users": "Kundenbenutzer",
|
|
@@ -50,6 +50,13 @@ import {
|
|
|
50
50
|
} from '../../../lib/personCompanyLinkTable'
|
|
51
51
|
import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
|
|
52
52
|
import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
|
|
53
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
54
|
+
import {
|
|
55
|
+
buildCollectionTags,
|
|
56
|
+
canonicalizeResourceTag,
|
|
57
|
+
isCrudCacheEnabled,
|
|
58
|
+
resolveCrudCache,
|
|
59
|
+
} from '@open-mercato/shared/lib/crud/cache'
|
|
53
60
|
|
|
54
61
|
export const metadata = {
|
|
55
62
|
GET: { requireAuth: true, requireFeatures: ['customers.companies.view'] },
|
|
@@ -59,6 +66,66 @@ const paramsSchema = z.object({
|
|
|
59
66
|
id: z.string().uuid(),
|
|
60
67
|
})
|
|
61
68
|
|
|
69
|
+
const COMPANY_DETAIL_CACHE_TTL_MS = 60_000
|
|
70
|
+
|
|
71
|
+
// The customers write commands already invalidate these resource collections via
|
|
72
|
+
// invalidateCrudCache using these exact resourceKind strings. Reusing them as
|
|
73
|
+
// cache tags means the cached detail is flushed by those existing commands with
|
|
74
|
+
// no new wiring (#3664, mirroring #3143). Canonicalize each kind the same way
|
|
75
|
+
// invalidateCrudCache does (camelCase split + lowercase) so the tag shapes match.
|
|
76
|
+
const COMPANY_DETAIL_CACHE_RESOURCE_KINDS = [
|
|
77
|
+
'customers.company',
|
|
78
|
+
'customers.address',
|
|
79
|
+
'customers.tagAssignment',
|
|
80
|
+
'customers.labelAssignment',
|
|
81
|
+
'customers.personCompanyLink',
|
|
82
|
+
'customers.interaction',
|
|
83
|
+
'customers.activity',
|
|
84
|
+
] as const
|
|
85
|
+
|
|
86
|
+
function buildCompanyDetailCacheTags(tenantId: string | null, organizationId: string | null): string[] {
|
|
87
|
+
const tags = new Set<string>()
|
|
88
|
+
for (const resourceKind of COMPANY_DETAIL_CACHE_RESOURCE_KINDS) {
|
|
89
|
+
const resource = canonicalizeResourceTag(resourceKind) ?? resourceKind
|
|
90
|
+
for (const tag of buildCollectionTags(resource, tenantId, [organizationId])) {
|
|
91
|
+
tags.add(tag)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Array.from(tags)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildCompanyDetailCacheKey(parts: {
|
|
98
|
+
tenantId: string | null
|
|
99
|
+
organizationId: string | null
|
|
100
|
+
companyId: string
|
|
101
|
+
selectedOrganizationId: string | null
|
|
102
|
+
filterIds: string[] | null
|
|
103
|
+
allowedIds: string[] | null
|
|
104
|
+
isSuperAdmin: boolean
|
|
105
|
+
viewerUserId: string | null
|
|
106
|
+
interactionMode: string
|
|
107
|
+
includeTokens: string[]
|
|
108
|
+
}): string {
|
|
109
|
+
const sortKeys = (values: string[]): string[] =>
|
|
110
|
+
[...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
111
|
+
const filterIds = parts.filterIds ? sortKeys(parts.filterIds).join(',') : 'all'
|
|
112
|
+
const allowedIds = parts.allowedIds ? sortKeys(parts.allowedIds).join(',') : 'all'
|
|
113
|
+
const includeTokens = sortKeys(parts.includeTokens).join(',')
|
|
114
|
+
return [
|
|
115
|
+
'customers:companies:detail',
|
|
116
|
+
`tenant:${parts.tenantId ?? 'null'}`,
|
|
117
|
+
`org:${parts.organizationId ?? 'null'}`,
|
|
118
|
+
`company:${parts.companyId}`,
|
|
119
|
+
`selected:${parts.selectedOrganizationId ?? 'null'}`,
|
|
120
|
+
`filter:${filterIds}`,
|
|
121
|
+
`allowed:${allowedIds}`,
|
|
122
|
+
`super:${parts.isSuperAdmin ? '1' : '0'}`,
|
|
123
|
+
`viewer:${parts.viewerUserId ?? 'null'}`,
|
|
124
|
+
`mode:${parts.interactionMode}`,
|
|
125
|
+
`include:${includeTokens}`,
|
|
126
|
+
].join('|')
|
|
127
|
+
}
|
|
128
|
+
|
|
62
129
|
function parseIncludeParams(request: Request): Set<string> {
|
|
63
130
|
const url = new URL(request.url)
|
|
64
131
|
const raw = url.searchParams.getAll('include')
|
|
@@ -414,6 +481,31 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
|
|
|
414
481
|
userFeatures: undefined,
|
|
415
482
|
})
|
|
416
483
|
|
|
484
|
+
// Opt-in cache (#3664): the company existence + org read-access checks above
|
|
485
|
+
// always run, so authorization is never served from cache. Only the expensive
|
|
486
|
+
// detail sweeps below are cached. Key on tenant/org/company + the RBAC scope,
|
|
487
|
+
// viewer (email privacy), interaction mode, and the requested include set.
|
|
488
|
+
const cacheTenantId = companyScope.tenantId
|
|
489
|
+
const cache = isCrudCacheEnabled() ? resolveCrudCache(container) : null
|
|
490
|
+
const cacheKey = cache
|
|
491
|
+
? buildCompanyDetailCacheKey({
|
|
492
|
+
tenantId: cacheTenantId,
|
|
493
|
+
organizationId: companyScope.organizationId,
|
|
494
|
+
companyId: company.id,
|
|
495
|
+
selectedOrganizationId: scope?.selectedId ?? auth.orgId ?? null,
|
|
496
|
+
filterIds: scope?.filterIds ?? null,
|
|
497
|
+
allowedIds: scope?.allowedIds ?? null,
|
|
498
|
+
isSuperAdmin: auth.isSuperAdmin === true,
|
|
499
|
+
viewerUserId,
|
|
500
|
+
interactionMode,
|
|
501
|
+
includeTokens: Array.from(includeTokens),
|
|
502
|
+
})
|
|
503
|
+
: null
|
|
504
|
+
if (cache && cacheKey) {
|
|
505
|
+
const cached = await runWithCacheTenant(cacheTenantId, () => cache.get(cacheKey))
|
|
506
|
+
if (cached) return NextResponse.json(cached)
|
|
507
|
+
}
|
|
508
|
+
|
|
417
509
|
const shouldLoadCanonicalInteractions = includeInteractions || includeActivities || includeTodos
|
|
418
510
|
|
|
419
511
|
// These reads only depend on the resolved company + scope + email visibility
|
|
@@ -961,7 +1053,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
|
|
|
961
1053
|
addresses: addressesCount,
|
|
962
1054
|
}
|
|
963
1055
|
|
|
964
|
-
|
|
1056
|
+
const payload = {
|
|
965
1057
|
interactionMode,
|
|
966
1058
|
company: {
|
|
967
1059
|
id: company.id,
|
|
@@ -1170,7 +1262,22 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
|
|
|
1170
1262
|
name: viewerUserId ? userMap.get(viewerUserId)?.name ?? null : null,
|
|
1171
1263
|
email: viewerUserId ? userMap.get(viewerUserId)?.email ?? auth.email ?? null : auth.email ?? null,
|
|
1172
1264
|
},
|
|
1173
|
-
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (cache && cacheKey) {
|
|
1268
|
+
try {
|
|
1269
|
+
await runWithCacheTenant(cacheTenantId, () =>
|
|
1270
|
+
cache.set(cacheKey, payload, {
|
|
1271
|
+
ttl: COMPANY_DETAIL_CACHE_TTL_MS,
|
|
1272
|
+
tags: buildCompanyDetailCacheTags(cacheTenantId, companyScope.organizationId),
|
|
1273
|
+
}),
|
|
1274
|
+
)
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
console.warn('[customers:companies] Failed to set company detail cache', err)
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return NextResponse.json(payload)
|
|
1174
1281
|
}
|
|
1175
1282
|
|
|
1176
1283
|
const companyDetailQuerySchema = z.object({
|
|
@@ -24,6 +24,15 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
24
24
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
25
25
|
import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
|
|
26
26
|
import { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'
|
|
27
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
28
|
+
import {
|
|
29
|
+
buildCollectionTags,
|
|
30
|
+
debugCrudCache,
|
|
31
|
+
expandResourceAliases,
|
|
32
|
+
isCrudCacheEnabled,
|
|
33
|
+
resolveCrudCache,
|
|
34
|
+
} from '@open-mercato/shared/lib/crud/cache'
|
|
35
|
+
import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
27
36
|
import { isMissingDealStageTransitionTable, warnMissingDealStageTransitionTable } from '../../../lib/dealStageTransitionTable'
|
|
28
37
|
|
|
29
38
|
export const metadata = {
|
|
@@ -324,6 +333,72 @@ async function resolveEffectivePipelineStage(
|
|
|
324
333
|
return null
|
|
325
334
|
}
|
|
326
335
|
|
|
336
|
+
const DEAL_DETAIL_CACHE_RESOURCE = 'customers.deal'
|
|
337
|
+
// Every entity the detail payload reads, expressed with the same resourceKind
|
|
338
|
+
// strings the customers commands declare. `expandResourceAliases` canonicalizes
|
|
339
|
+
// them exactly the way `invalidateCrudCache` does on the write side (camelCase is
|
|
340
|
+
// split, `_`/`-` become `.`), so `customers.pipelineStage` resolves to
|
|
341
|
+
// `customers.pipeline.stage` and `customers.dictionary_entry` to
|
|
342
|
+
// `customers.dictionary.entry`. That guarantees the collection tags stored here
|
|
343
|
+
// match the ones the deal/pipeline/stage/dictionary commands flush.
|
|
344
|
+
const DEAL_DETAIL_CACHE_TAG_RESOURCES = [
|
|
345
|
+
'customers.pipeline',
|
|
346
|
+
'customers.pipelineStage',
|
|
347
|
+
'customers.dictionary_entry',
|
|
348
|
+
]
|
|
349
|
+
const DEAL_DETAIL_CACHE_TTL_MS = 60_000
|
|
350
|
+
|
|
351
|
+
type DealDetailCacheAuth = {
|
|
352
|
+
isSuperAdmin?: boolean | null
|
|
353
|
+
orgId?: string | null
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Capture every input `isOrganizationReadAccessAllowed` consults, so two requests
|
|
357
|
+
// that share a signature also share an authorization outcome for the same deal.
|
|
358
|
+
// The lookup runs after the access check, so this is defense in depth plus the
|
|
359
|
+
// per-scope cache partitioning the issue asks for.
|
|
360
|
+
function buildDealDetailScopeSignature(
|
|
361
|
+
auth: DealDetailCacheAuth,
|
|
362
|
+
scope: Pick<OrganizationScope, 'allowedIds' | 'filterIds'> | null | undefined,
|
|
363
|
+
): string {
|
|
364
|
+
if (auth?.isSuperAdmin === true) return 'super'
|
|
365
|
+
if (scope?.allowedIds === null) return 'all'
|
|
366
|
+
const filterIds = Array.isArray(scope?.filterIds)
|
|
367
|
+
? scope!.filterIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
|
|
368
|
+
: []
|
|
369
|
+
if (filterIds.length) return `filter:${[...filterIds].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)).join(',')}`
|
|
370
|
+
return `home:${auth?.orgId ?? 'null'}`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function buildDealDetailCacheKey(params: {
|
|
374
|
+
tenantId: string | null
|
|
375
|
+
organizationId: string | null
|
|
376
|
+
dealId: string
|
|
377
|
+
scopeSignature: string
|
|
378
|
+
view: 'full' | 'lite'
|
|
379
|
+
includeKey: string
|
|
380
|
+
}): string {
|
|
381
|
+
return [
|
|
382
|
+
'customers:deal:detail',
|
|
383
|
+
`tenant=${params.tenantId ?? 'null'}`,
|
|
384
|
+
`org=${params.organizationId ?? 'null'}`,
|
|
385
|
+
`deal=${params.dealId}`,
|
|
386
|
+
`scope=${params.scopeSignature}`,
|
|
387
|
+
`view=${params.view}`,
|
|
388
|
+
`include=${params.includeKey}`,
|
|
389
|
+
].join(':')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildDealDetailCacheTags(tenantId: string | null, organizationId: string | null): string[] {
|
|
393
|
+
const tags = new Set<string>()
|
|
394
|
+
for (const key of expandResourceAliases(DEAL_DETAIL_CACHE_RESOURCE, DEAL_DETAIL_CACHE_TAG_RESOURCES)) {
|
|
395
|
+
for (const tag of buildCollectionTags(key, tenantId, [organizationId])) {
|
|
396
|
+
tags.add(tag)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return Array.from(tags)
|
|
400
|
+
}
|
|
401
|
+
|
|
327
402
|
export async function GET(request: Request, context: { params?: Record<string, unknown> }) {
|
|
328
403
|
const parsedParams = paramsSchema.safeParse(context.params)
|
|
329
404
|
if (!parsedParams.success) {
|
|
@@ -386,6 +461,69 @@ export async function GET(request: Request, context: { params?: Record<string, u
|
|
|
386
461
|
tenantId: deal.tenantId ?? auth.tenantId ?? null,
|
|
387
462
|
organizationId: deal.organizationId ?? auth.orgId ?? null,
|
|
388
463
|
}
|
|
464
|
+
|
|
465
|
+
const viewerUserId = auth.isApiKey ? null : auth.sub ?? null
|
|
466
|
+
const resolveViewer = async (): Promise<{ name: string | null; email: string | null }> => {
|
|
467
|
+
if (!viewerUserId) {
|
|
468
|
+
return { name: null, email: auth.email ?? null }
|
|
469
|
+
}
|
|
470
|
+
const viewer = await findOneWithDecryption(
|
|
471
|
+
em,
|
|
472
|
+
User,
|
|
473
|
+
{ id: viewerUserId, tenantId: auth.tenantId ?? null },
|
|
474
|
+
{},
|
|
475
|
+
{ tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
|
|
476
|
+
)
|
|
477
|
+
return {
|
|
478
|
+
name: viewer?.name ?? null,
|
|
479
|
+
email: viewer?.email ?? auth.email ?? null,
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Opt-in detail cache (ENABLE_CRUD_API_CACHE). The lookup runs after the deal is
|
|
484
|
+
// resolved and the tenant + organization read-access checks pass, so the cache
|
|
485
|
+
// only ever memoizes a payload this caller is already authorized to see; it then
|
|
486
|
+
// skips the heavy association / custom-field / pipeline-stage / dictionary /
|
|
487
|
+
// audit-fallback sweeps below. The viewer block is request-specific, so it is
|
|
488
|
+
// excluded from the cached value and always resolved fresh.
|
|
489
|
+
const cacheTenantId = deal.tenantId ?? auth.tenantId ?? null
|
|
490
|
+
const cacheOrganizationId = deal.organizationId ?? null
|
|
491
|
+
const detailCache = isCrudCacheEnabled() ? resolveCrudCache(container) : null
|
|
492
|
+
const includeKey = includeFlags.size ? Array.from(includeFlags).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)).join(',') : 'none'
|
|
493
|
+
const detailCacheKey = detailCache
|
|
494
|
+
? buildDealDetailCacheKey({
|
|
495
|
+
tenantId: cacheTenantId,
|
|
496
|
+
organizationId: cacheOrganizationId,
|
|
497
|
+
dealId: deal.id,
|
|
498
|
+
scopeSignature: buildDealDetailScopeSignature(auth, scope),
|
|
499
|
+
view: viewMode,
|
|
500
|
+
includeKey,
|
|
501
|
+
})
|
|
502
|
+
: null
|
|
503
|
+
|
|
504
|
+
if (detailCache && detailCacheKey) {
|
|
505
|
+
try {
|
|
506
|
+
const cached = await runWithCacheTenant(cacheTenantId, () => detailCache.get(detailCacheKey))
|
|
507
|
+
if (cached && typeof cached === 'object') {
|
|
508
|
+
const viewerInfo = await resolveViewer()
|
|
509
|
+
return NextResponse.json({
|
|
510
|
+
...(cached as Record<string, unknown>),
|
|
511
|
+
viewer: {
|
|
512
|
+
userId: viewerUserId,
|
|
513
|
+
name: viewerInfo.name,
|
|
514
|
+
email: viewerInfo.email,
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
} catch (err) {
|
|
519
|
+
// A cache-backend read error must degrade to a fresh DB read, never a 500.
|
|
520
|
+
debugCrudCache('get', {
|
|
521
|
+
resource: DEAL_DETAIL_CACHE_RESOURCE,
|
|
522
|
+
key: detailCacheKey,
|
|
523
|
+
error: err instanceof Error ? err.message : String(err),
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
}
|
|
389
527
|
// Issue #3175: the reads below only depend on the already-resolved deal +
|
|
390
528
|
// organization scope + decryption scope, so they are grouped into Promise.all
|
|
391
529
|
// batches instead of being awaited one after another. True dependencies stay
|
|
@@ -535,24 +673,6 @@ export async function GET(request: Request, context: { params?: Record<string, u
|
|
|
535
673
|
}
|
|
536
674
|
}
|
|
537
675
|
|
|
538
|
-
const viewerUserId = auth.isApiKey ? null : auth.sub ?? null
|
|
539
|
-
const resolveViewer = async (): Promise<{ name: string | null; email: string | null }> => {
|
|
540
|
-
if (!viewerUserId) {
|
|
541
|
-
return { name: null, email: auth.email ?? null }
|
|
542
|
-
}
|
|
543
|
-
const viewer = await findOneWithDecryption(
|
|
544
|
-
em,
|
|
545
|
-
User,
|
|
546
|
-
{ id: viewerUserId, tenantId: auth.tenantId ?? null },
|
|
547
|
-
{},
|
|
548
|
-
{ tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
|
|
549
|
-
)
|
|
550
|
-
return {
|
|
551
|
-
name: viewer?.name ?? null,
|
|
552
|
-
email: viewer?.email ?? auth.email ?? null,
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
676
|
const resolveOwner = async () =>
|
|
557
677
|
deal.ownerUserId
|
|
558
678
|
? await findOneWithDecryption(
|
|
@@ -682,7 +802,15 @@ export async function GET(request: Request, context: { params?: Record<string, u
|
|
|
682
802
|
fallbackTimestamp: deal.createdAt.toISOString(),
|
|
683
803
|
})
|
|
684
804
|
|
|
685
|
-
|
|
805
|
+
const viewer = {
|
|
806
|
+
userId: viewerUserId,
|
|
807
|
+
name: viewerName,
|
|
808
|
+
email: viewerEmail,
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Everything except the request-specific viewer block is shareable across
|
|
812
|
+
// authorized callers, so it is what gets cached.
|
|
813
|
+
const cacheableBody = {
|
|
686
814
|
deal: {
|
|
687
815
|
id: deal.id,
|
|
688
816
|
title: deal.title,
|
|
@@ -714,11 +842,6 @@ export async function GET(request: Request, context: { params?: Record<string, u
|
|
|
714
842
|
companies: linkedCompanyIds.length,
|
|
715
843
|
},
|
|
716
844
|
customFields,
|
|
717
|
-
viewer: {
|
|
718
|
-
userId: viewerUserId,
|
|
719
|
-
name: viewerName,
|
|
720
|
-
email: viewerEmail,
|
|
721
|
-
},
|
|
722
845
|
pipelineStages: pipelineStages.map((stage) => {
|
|
723
846
|
const appearance = pipelineStageAppearanceMap.get(stage.label.trim().toLowerCase())
|
|
724
847
|
return {
|
|
@@ -732,7 +855,28 @@ export async function GET(request: Request, context: { params?: Record<string, u
|
|
|
732
855
|
pipelineName: pipeline?.name ?? null,
|
|
733
856
|
stageTransitions: stageTransitionPayload,
|
|
734
857
|
owner: ownerPayload,
|
|
735
|
-
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (detailCache && detailCacheKey) {
|
|
861
|
+
try {
|
|
862
|
+
await runWithCacheTenant(cacheTenantId, () =>
|
|
863
|
+
detailCache.set(detailCacheKey, cacheableBody, {
|
|
864
|
+
ttl: DEAL_DETAIL_CACHE_TTL_MS,
|
|
865
|
+
tags: buildDealDetailCacheTags(cacheTenantId, cacheOrganizationId),
|
|
866
|
+
}),
|
|
867
|
+
)
|
|
868
|
+
} catch (err) {
|
|
869
|
+
// A cache write must never break the request; log it for observability
|
|
870
|
+
// instead of failing the response (matches the CRUD factory).
|
|
871
|
+
debugCrudCache('store', {
|
|
872
|
+
resource: DEAL_DETAIL_CACHE_RESOURCE,
|
|
873
|
+
key: detailCacheKey,
|
|
874
|
+
error: err instanceof Error ? err.message : String(err),
|
|
875
|
+
})
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return NextResponse.json({ ...cacheableBody, viewer })
|
|
736
880
|
}
|
|
737
881
|
|
|
738
882
|
const dealDetailQuerySchema = z.object({
|