@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.
@@ -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": "Manage addresses",
107
- "customer_accounts.admin.portalFeatures.addresses.view": "View addresses",
108
- "customer_accounts.admin.portalFeatures.groups.addresses": "Addresses",
109
- "customer_accounts.admin.portalFeatures.groups.invoices": "Invoices",
110
- "customer_accounts.admin.portalFeatures.groups.orders": "Orders",
111
- "customer_accounts.admin.portalFeatures.groups.profile": "Profile",
112
- "customer_accounts.admin.portalFeatures.groups.quotes": "Quotes",
113
- "customer_accounts.admin.portalFeatures.groups.users": "Users",
114
- "customer_accounts.admin.portalFeatures.invoices.view": "View invoices",
115
- "customer_accounts.admin.portalFeatures.orders.create": "Create orders",
116
- "customer_accounts.admin.portalFeatures.orders.view": "View orders",
117
- "customer_accounts.admin.portalFeatures.profile.edit": "Edit profile",
118
- "customer_accounts.admin.portalFeatures.profile.view": "View profile",
119
- "customer_accounts.admin.portalFeatures.quotes.request": "Request quotes",
120
- "customer_accounts.admin.portalFeatures.quotes.view": "View quotes",
121
- "customer_accounts.admin.portalFeatures.users.invite": "Invite team members",
122
- "customer_accounts.admin.portalFeatures.users.manage": "Manage team members",
123
- "customer_accounts.admin.portalFeatures.users.view": "View team members",
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": "Cancel",
131
- "customer_accounts.admin.roleCreate.actions.create": "Create Role",
132
- "customer_accounts.admin.roleCreate.actions.saving": "Creating...",
133
- "customer_accounts.admin.roleCreate.error.nameRequired": "Name is required",
134
- "customer_accounts.admin.roleCreate.error.required": "Name and slug are required",
135
- "customer_accounts.admin.roleCreate.error.save": "Failed to create role",
136
- "customer_accounts.admin.roleCreate.error.slugFormat": "Slug must contain only lowercase letters, numbers, hyphens, and underscores",
137
- "customer_accounts.admin.roleCreate.error.slugRequired": "Slug is required",
138
- "customer_accounts.admin.roleCreate.fields.customerAssignable": "Customers can self-assign",
139
- "customer_accounts.admin.roleCreate.fields.description": "Description",
140
- "customer_accounts.admin.roleCreate.fields.isDefault": "Default role (auto-assigned to new users)",
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": "e.g. Buyer",
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": "Lowercase letters, numbers, hyphens, and underscores only.",
145
- "customer_accounts.admin.roleCreate.fields.slugPlaceholder": "e.g. buyer",
146
- "customer_accounts.admin.roleCreate.flash.created": "Role created",
147
- "customer_accounts.admin.roleCreate.sections.details": "Role Details",
148
- "customer_accounts.admin.roleCreate.sections.options": "Options",
149
- "customer_accounts.admin.roleCreate.title": "Create Customer Role",
150
- "customer_accounts.admin.roleDetail.actions.backToList": "Back to roles",
151
- "customer_accounts.admin.roleDetail.actions.cancel": "Cancel",
152
- "customer_accounts.admin.roleDetail.actions.delete": "Delete",
153
- "customer_accounts.admin.roleDetail.actions.save": "Save Changes",
154
- "customer_accounts.admin.roleDetail.actions.saving": "Saving...",
155
- "customer_accounts.admin.roleDetail.error.load": "Failed to load role",
156
- "customer_accounts.admin.roleDetail.error.notFound": "Role not found",
157
- "customer_accounts.admin.roleDetail.error.save": "Failed to save role",
158
- "customer_accounts.admin.roleDetail.error.saveAcl": "Failed to save permissions",
159
- "customer_accounts.admin.roleDetail.fields.customerAssignable": "Customers can self-assign",
160
- "customer_accounts.admin.roleDetail.fields.description": "Description",
161
- "customer_accounts.admin.roleDetail.fields.isDefault": "Default role (auto-assigned to new users)",
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": "Role updated",
164
- "customer_accounts.admin.roleDetail.loading": "Loading role...",
165
- "customer_accounts.admin.roleDetail.sections.details": "Role Details",
166
- "customer_accounts.admin.roleDetail.sections.options": "Options",
167
- "customer_accounts.admin.roleDetail.sections.permissions": "Portal Permissions",
168
- "customer_accounts.admin.roleDetail.selectAll": "Select all",
169
- "customer_accounts.admin.roles": "Roles",
170
- "customer_accounts.admin.roles.actions.create": "Create Role",
171
- "customer_accounts.admin.roles.actions.delete": "Delete",
172
- "customer_accounts.admin.roles.actions.edit": "Edit",
173
- "customer_accounts.admin.roles.assignable": "Yes",
174
- "customer_accounts.admin.roles.columns.customerAssignable": "Self-assignable",
175
- "customer_accounts.admin.roles.columns.description": "Description",
176
- "customer_accounts.admin.roles.columns.isDefault": "Default",
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": "Delete role \"{{name}}\"?",
181
- "customer_accounts.admin.roles.default": "Default",
182
- "customer_accounts.admin.roles.error.delete": "Failed to delete role",
183
- "customer_accounts.admin.roles.error.deleteSystem": "System roles cannot be deleted",
184
- "customer_accounts.admin.roles.error.load": "Failed to load roles",
185
- "customer_accounts.admin.roles.flash.deleted": "Role deleted",
186
- "customer_accounts.admin.roles.notAssignable": "No",
187
- "customer_accounts.admin.roles.searchPlaceholder": "Search roles...",
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": "Create Role",
267
- "customer_accounts.nav.role_detail": "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
- return NextResponse.json({
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
- return NextResponse.json({
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({