@open-mercato/core 0.6.8-develop.6944.1.eef6a0ee1d → 0.6.8-develop.6948.1.8369fc4c97

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 (51) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/authFixtures.js +3 -0
  3. package/dist/helpers/integration/authFixtures.js.map +2 -2
  4. package/dist/modules/auth/api/users/acl/route.js +15 -2
  5. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  6. package/dist/modules/auth/api/users/consents/route.js +8 -1
  7. package/dist/modules/auth/api/users/consents/route.js.map +2 -2
  8. package/dist/modules/auth/api/users/resend-invite/route.js +8 -1
  9. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  10. package/dist/modules/auth/api/users/route.js +80 -6
  11. package/dist/modules/auth/api/users/route.js.map +2 -2
  12. package/dist/modules/auth/commands/users.js +49 -2
  13. package/dist/modules/auth/commands/users.js.map +2 -2
  14. package/dist/modules/auth/lib/grantChecks.js +86 -5
  15. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  16. package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
  17. package/dist/modules/customer_accounts/lib/customerAuth.js +19 -11
  18. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  19. package/dist/modules/customer_accounts/lib/customerAuthServer.js +14 -7
  20. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  21. package/dist/modules/customer_accounts/services/customerSessionService.js +14 -0
  22. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  23. package/dist/modules/customers/api/interactions/route.js +16 -5
  24. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  25. package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
  26. package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
  27. package/dist/modules/customers/components/calendar/editor/hooks.js +2 -2
  28. package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
  29. package/dist/modules/customers/components/calendar/useCalendarItems.js +33 -7
  30. package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
  31. package/package.json +7 -7
  32. package/src/helpers/integration/authFixtures.ts +5 -2
  33. package/src/modules/auth/api/users/acl/route.ts +13 -0
  34. package/src/modules/auth/api/users/consents/route.ts +7 -0
  35. package/src/modules/auth/api/users/resend-invite/route.ts +7 -0
  36. package/src/modules/auth/api/users/route.ts +80 -4
  37. package/src/modules/auth/commands/users.ts +54 -2
  38. package/src/modules/auth/i18n/de.json +4 -0
  39. package/src/modules/auth/i18n/en.json +4 -0
  40. package/src/modules/auth/i18n/es.json +4 -0
  41. package/src/modules/auth/i18n/ko.json +4 -0
  42. package/src/modules/auth/i18n/pl.json +4 -0
  43. package/src/modules/auth/lib/grantChecks.ts +121 -5
  44. package/src/modules/auth/lib/sessionIntegrity.ts +5 -4
  45. package/src/modules/customer_accounts/lib/customerAuth.ts +31 -12
  46. package/src/modules/customer_accounts/lib/customerAuthServer.ts +19 -7
  47. package/src/modules/customer_accounts/services/customerSessionService.ts +20 -0
  48. package/src/modules/customers/api/interactions/route.ts +16 -5
  49. package/src/modules/customers/components/calendar/CalendarScreen.tsx +5 -5
  50. package/src/modules/customers/components/calendar/editor/hooks.ts +2 -2
  51. package/src/modules/customers/components/calendar/useCalendarItems.ts +43 -6
@@ -1,9 +1,11 @@
1
1
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
- import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
2
+ import { badRequest, CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
3
  import { hasFeature } from '@open-mercato/shared/security/features'
4
4
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
6
  import { Role, RoleAcl, User, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
6
7
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
7
9
 
8
10
  type ActorAcl = {
9
11
  isSuperAdmin: boolean
@@ -27,6 +29,21 @@ type RoleTokenGrantCheckInput = GrantCheckContext & {
27
29
  roleTokens: unknown
28
30
  }
29
31
 
32
+ type UserDestinationRolesInput = {
33
+ em: EntityManager
34
+ targetUserId: string
35
+ destinationTenantId: string | null | undefined
36
+ roleTokens: unknown
37
+ }
38
+
39
+ type UserDestinationScopeCheckInput = GrantCheckContext & {
40
+ actorIsSuperAdmin?: boolean
41
+ allowedOrganizationIds?: string[] | null
42
+ destinationTenantId: string | null | undefined
43
+ destinationOrganizationId: string | null | undefined
44
+ roles: Role[]
45
+ }
46
+
30
47
  type FeatureGrantCheckInput = GrantCheckContext & {
31
48
  features: unknown
32
49
  isSuperAdmin?: boolean
@@ -38,6 +55,10 @@ type SuperAdminUserTargetInput = GrantCheckContext & {
38
55
  actorIsSuperAdmin?: boolean
39
56
  }
40
57
 
58
+ type UserTargetAccessInput = SuperAdminUserTargetInput & {
59
+ organizationScope: Pick<OrganizationScope, 'allowedIds'>
60
+ }
61
+
41
62
  type SuperAdminRoleTargetInput = GrantCheckContext & {
42
63
  targetRoleId: string
43
64
  actorIsSuperAdmin?: boolean
@@ -55,6 +76,97 @@ export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckIn
55
76
  return roles
56
77
  }
57
78
 
79
+ export async function resolveUserDestinationRoles(input: UserDestinationRolesInput): Promise<Role[]> {
80
+ const destinationTenantId = normalizeNullableString(input.destinationTenantId)
81
+ if (Array.isArray(input.roleTokens)) {
82
+ return resolveRolesForGrant(input.em, normalizeStringList(input.roleTokens), destinationTenantId)
83
+ }
84
+
85
+ const links = await findWithDecryption(
86
+ input.em,
87
+ UserRole,
88
+ { user: input.targetUserId as unknown as User } as FilterQuery<UserRole>,
89
+ { populate: ['role'] },
90
+ { tenantId: null, organizationId: null },
91
+ )
92
+ const roles: Role[] = []
93
+ for (const link of links) {
94
+ const linkedRole = (link as { role?: Role | string | null }).role
95
+ if (linkedRole && typeof linkedRole === 'object') {
96
+ roles.push(linkedRole)
97
+ continue
98
+ }
99
+ if (typeof linkedRole === 'string') {
100
+ const resolvedRole = await resolveRoleForGrant(input.em, linkedRole, null)
101
+ if (resolvedRole) {
102
+ roles.push(resolvedRole)
103
+ continue
104
+ }
105
+ }
106
+ throw badRequest(await translateAuthError(
107
+ 'auth.users.errors.invalidRoleAssignment',
108
+ 'User has an invalid role assignment',
109
+ ))
110
+ }
111
+ return roles
112
+ }
113
+
114
+ export async function assertActorCanAssignUserDestination(
115
+ input: UserDestinationScopeCheckInput,
116
+ ): Promise<void> {
117
+ const destinationTenantId = normalizeNullableString(input.destinationTenantId)
118
+ const destinationOrganizationId = normalizeNullableString(input.destinationOrganizationId)
119
+ if (!destinationTenantId || !destinationOrganizationId) {
120
+ return throwUserDestinationOrganizationNotFound(400)
121
+ }
122
+
123
+ for (const role of input.roles) {
124
+ if (normalizeNullableString(role.tenantId) !== destinationTenantId) {
125
+ throw forbidden(await translateAuthError(
126
+ 'auth.users.errors.roleOutsideDestinationTenant',
127
+ 'Cannot retain or assign a role outside the destination tenant.',
128
+ ))
129
+ }
130
+ }
131
+
132
+ if (await resolveActorIsSuperAdmin(input)) return
133
+
134
+ const actorTenantId = normalizeNullableString(input.tenantId)
135
+ if (!actorTenantId || actorTenantId !== destinationTenantId) {
136
+ return throwUserDestinationOrganizationNotFound(404)
137
+ }
138
+
139
+ const actorAcl = await loadActorAcl(input)
140
+ const allowedOrganizationIds = input.allowedOrganizationIds === undefined
141
+ ? actorAcl.organizations
142
+ : input.allowedOrganizationIds
143
+ if (
144
+ allowedOrganizationIds !== null
145
+ && !allowedOrganizationIds.includes('__all__')
146
+ && !allowedOrganizationIds.includes(destinationOrganizationId)
147
+ ) {
148
+ throw forbidden(await translateAuthError(
149
+ 'auth.users.errors.destinationOrganizationOutsideScope',
150
+ 'Cannot assign user to a destination organization outside actor scope.',
151
+ ))
152
+ }
153
+
154
+ await assertActorCanGrantRoles({
155
+ ...input,
156
+ tenantId: destinationTenantId,
157
+ roles: input.roles,
158
+ })
159
+ }
160
+
161
+ export async function throwUserDestinationOrganizationNotFound(status: 400 | 404): Promise<never> {
162
+ throw new CrudHttpError(status, {
163
+ error: await translateAuthError(
164
+ 'auth.users.errors.organizationNotFound',
165
+ 'Organization not found',
166
+ ),
167
+ })
168
+ }
169
+
58
170
  export async function assertActorCanGrantRoles(input: RoleGrantCheckInput): Promise<void> {
59
171
  if (!input.roles.length) return
60
172
 
@@ -127,7 +239,7 @@ export async function assertActorCanModifySuperAdminRoleTarget(input: SuperAdmin
127
239
  }
128
240
  }
129
241
 
130
- export async function assertActorCanAccessUserTarget(input: SuperAdminUserTargetInput): Promise<void> {
242
+ export async function assertActorCanAccessUserTarget(input: UserTargetAccessInput): Promise<void> {
131
243
  const isSuperAdmin = await resolveActorIsSuperAdmin(input)
132
244
  if (isSuperAdmin) return
133
245
 
@@ -151,10 +263,9 @@ export async function assertActorCanAccessUserTarget(input: SuperAdminUserTarget
151
263
  throw new CrudHttpError(404, { error: 'User not found' })
152
264
  }
153
265
 
154
- const actorAcl = await loadActorAcl(input)
155
- if (actorAcl.organizations !== null && !actorAcl.organizations.includes('__all__')) {
266
+ if (input.organizationScope.allowedIds !== null) {
156
267
  const targetOrganizationId = normalizeNullableString((target as { organizationId?: string | null }).organizationId)
157
- if (!targetOrganizationId || !actorAcl.organizations.includes(targetOrganizationId)) {
268
+ if (!targetOrganizationId || !input.organizationScope.allowedIds.includes(targetOrganizationId)) {
158
269
  throw forbidden('Not authorized to access this user.')
159
270
  }
160
271
  }
@@ -400,3 +511,8 @@ function normalizeNullableString(value: unknown): string | null {
400
511
  function isWildcardFeature(feature: string): boolean {
401
512
  return feature.endsWith('.*')
402
513
  }
514
+
515
+ async function translateAuthError(key: string, fallback: string): Promise<string> {
516
+ const { translate } = await resolveTranslations()
517
+ return translate(key, fallback)
518
+ }
@@ -48,10 +48,11 @@ export async function resolveCanonicalStaffAuthContext(
48
48
  // still exist (not soft-deleted, not expired). This is what makes logout / password-reset
49
49
  // actually invalidate an already-issued JWT.
50
50
  //
51
- // Legacy tokens (pre-migration, without `sid`) are allowed through during the grace period
52
- // (controlled by JWT_LEGACY_GRACE_MINUTES) so that rolling deployments don't force-logout
53
- // every user. Once the grace period expires these tokens will fail signature verification
54
- // in `verifyJwt` before reaching this point.
51
+ // Legacy tokens (pre-migration, without `sid`) are allowed through during the grace period so
52
+ // that rolling deployments don't force-logout every user. `verifyJwt` owns that window: it only
53
+ // marks a payload `_legacyToken` while the token's own `iat` is within JWT_LEGACY_GRACE_MINUTES
54
+ // and before JWT_LEGACY_CUTOVER_AT, so an aged or post-cutover raw-secret token fails
55
+ // verification before reaching this point and every remaining token must carry a live `sid`.
55
56
  const sessionId = normalizeScopeId(typeof auth.sid === 'string' ? auth.sid : null)
56
57
  if (sessionId === INVALID_SCOPE) return null
57
58
  if (sessionId === null) {
@@ -18,7 +18,12 @@ export interface CustomerAuthContext {
18
18
  isPortalAdmin?: boolean
19
19
  }
20
20
 
21
- async function assertSessionStillActive(sessionId: string): Promise<boolean> {
21
+ async function assertSessionStillActive(input: {
22
+ sessionId: string
23
+ userId: string
24
+ tenantId: string
25
+ organizationId: string
26
+ }): Promise<boolean> {
22
27
  try {
23
28
  const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([
24
29
  import('@open-mercato/shared/lib/di/container'),
@@ -26,7 +31,7 @@ async function assertSessionStillActive(sessionId: string): Promise<boolean> {
26
31
  ])
27
32
  const container = await createRequestContainer()
28
33
  const service = container.resolve('customerSessionService') as InstanceType<typeof CustomerSessionService>
29
- const session = await service.findActiveSessionById(sessionId)
34
+ const session = await service.findActiveSessionForClaims(input)
30
35
  return session !== null
31
36
  } catch {
32
37
  // Fail closed: if we cannot verify the session, treat the token as revoked to prevent
@@ -108,32 +113,46 @@ export async function getCustomerAuthFromRequest(req: Request): Promise<Customer
108
113
 
109
114
  try {
110
115
  let payload = verifyAudienceJwt(CUSTOMER_JWT_AUDIENCE, token) as Record<string, unknown> | null
111
- // Legacy fallback: try raw JWT_SECRET for pre-migration customer tokens
116
+ // Legacy fallback: accept a pre-migration customer token signed with the raw JWT_SECRET, but
117
+ // only while `verifyJwt` itself still considers it legacy — it owns the grace window (token
118
+ // `iat` vs JWT_LEGACY_GRACE_MINUTES / JWT_LEGACY_CUTOVER_AT) and marks the payload. Trusting
119
+ // the bare return value would also let a staff-audience token through this branch, because
120
+ // the default `verifyJwt` path verifies against the staff-derived key.
112
121
  if (!payload) {
113
- payload = verifyJwt(token) as Record<string, unknown> | null
114
- if (payload) payload._legacyToken = true
122
+ const legacyPayload = verifyJwt(token) as Record<string, unknown> | null
123
+ if (legacyPayload && legacyPayload._legacyToken === true) payload = legacyPayload
115
124
  }
116
125
  if (!payload) return null
117
126
  if (payload.type !== 'customer') return null
118
127
  const sid = typeof payload.sid === 'string' ? payload.sid : ''
119
128
  if (!sid && payload._legacyToken !== true) return null
120
- const stillActive = sid ? await assertSessionStillActive(sid) : true
129
+ const userId = String(payload.sub)
130
+ const tenantId = String(payload.tenantId)
131
+ const organizationId = String(payload.orgId)
132
+ const stillActive = sid
133
+ ? await assertSessionStillActive({
134
+ sessionId: sid,
135
+ userId,
136
+ tenantId,
137
+ organizationId,
138
+ })
139
+ : true
121
140
  if (!stillActive) return null
122
141
 
123
142
  const userState = await validateUserState(
124
- String(payload.sub),
125
- String(payload.tenantId),
126
- String(payload.orgId),
143
+ userId,
144
+ tenantId,
145
+ organizationId,
127
146
  payload.iat,
128
147
  )
129
148
  if (!userState.valid) return null
130
149
 
131
150
  return {
132
- sub: String(payload.sub),
151
+ sub: userId,
133
152
  sid,
134
153
  type: 'customer',
135
- tenantId: String(payload.tenantId),
136
- orgId: String(payload.orgId),
154
+ tenantId,
155
+ orgId: organizationId,
137
156
  email: String(payload.email || ''),
138
157
  displayName: String(payload.displayName || ''),
139
158
  customerEntityId: payload.customerEntityId ? String(payload.customerEntityId) : null,
@@ -20,11 +20,16 @@ const logger = createLogger('customer_accounts').child({ component: 'customer-au
20
20
 
21
21
  export type { CustomerAuthContext }
22
22
 
23
- async function assertSessionStillActive(sessionId: string): Promise<boolean> {
23
+ async function assertSessionStillActive(input: {
24
+ sessionId: string
25
+ userId: string
26
+ tenantId: string
27
+ organizationId: string
28
+ }): Promise<boolean> {
24
29
  try {
25
30
  const container = await createRequestContainer()
26
31
  const service = container.resolve('customerSessionService') as InstanceType<typeof CustomerSessionService>
27
- const session = await service.findActiveSessionById(sessionId)
32
+ const session = await service.findActiveSessionForClaims(input)
28
33
  return session !== null
29
34
  } catch {
30
35
  return false
@@ -60,29 +65,36 @@ export async function getCustomerAuthFromCookies(
60
65
  const sid = typeof payload.sid === 'string' ? payload.sid : ''
61
66
  if (!sid) return null
62
67
  const tenantId = String(payload.tenantId)
68
+ const userId = String(payload.sub)
69
+ const organizationId = String(payload.orgId)
63
70
  if (options?.expectedTenantId && options.expectedTenantId !== tenantId) {
64
71
  // Cross-host JWT replay defense. See spec rev 5 Customer Authentication
65
72
  // section: the host-resolved tenant is the authoritative scope; mismatched
66
73
  // JWTs are rejected as if unauthenticated.
67
74
  return null
68
75
  }
69
- const stillActive = await assertSessionStillActive(sid)
76
+ const stillActive = await assertSessionStillActive({
77
+ sessionId: sid,
78
+ userId,
79
+ tenantId,
80
+ organizationId,
81
+ })
70
82
  if (!stillActive) return null
71
83
 
72
84
  const userState = await validateUserState(
73
- String(payload.sub),
85
+ userId,
74
86
  tenantId,
75
- String(payload.orgId),
87
+ organizationId,
76
88
  payload.iat,
77
89
  )
78
90
  if (!userState.valid) return null
79
91
 
80
92
  return {
81
- sub: String(payload.sub),
93
+ sub: userId,
82
94
  sid,
83
95
  type: 'customer',
84
96
  tenantId,
85
- orgId: String(payload.orgId),
97
+ orgId: organizationId,
86
98
  email: String(payload.email || ''),
87
99
  displayName: String(payload.displayName || ''),
88
100
  customerEntityId: payload.customerEntityId ? String(payload.customerEntityId) : null,
@@ -106,6 +106,26 @@ export class CustomerSessionService {
106
106
  return session
107
107
  }
108
108
 
109
+ async findActiveSessionForClaims(input: {
110
+ sessionId: string
111
+ userId: string
112
+ tenantId: string
113
+ organizationId: string
114
+ }): Promise<CustomerUserSession | null> {
115
+ const session = await this.em.findOne(CustomerUserSession, {
116
+ id: input.sessionId,
117
+ user: {
118
+ id: input.userId,
119
+ tenantId: input.tenantId,
120
+ organizationId: input.organizationId,
121
+ },
122
+ deletedAt: null,
123
+ })
124
+ if (!session) return null
125
+ if (session.expiresAt.getTime() < Date.now()) return null
126
+ return session
127
+ }
128
+
109
129
  async revokeSession(sessionId: string): Promise<void> {
110
130
  await this.em.nativeUpdate(CustomerUserSession, { id: sessionId }, { deletedAt: new Date() })
111
131
  }
@@ -59,6 +59,7 @@ export const listSchema = z
59
59
  search: z.string().trim().min(1).optional(),
60
60
  from: z.coerce.date().optional(),
61
61
  to: z.coerce.date().optional(),
62
+ recurrenceMasters: z.enum(['true', 'false']).optional(),
62
63
  pinned: z.enum(['true', 'false']).optional(),
63
64
  sortField: interactionSortFieldSchema.optional(),
64
65
  sortDir: z.enum(['asc', 'desc']).optional(),
@@ -360,11 +361,21 @@ function applyInteractionListFilters(
360
361
  const searchTerm = `%${escapeLikePattern(query.search)}%`
361
362
  q = q.where(sql<boolean>`coalesce(title, '') ilike ${searchTerm} or coalesce(body, '') ilike ${searchTerm}`)
362
363
  }
363
- if (query.from) {
364
- q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`)
365
- }
366
- if (query.to) {
367
- q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)
364
+ if (query.recurrenceMasters === 'true') {
365
+ q = q.where('recurrence_rule', 'is not', null)
366
+ if (query.from) {
367
+ q = q.where(sql<boolean>`recurrence_end is null or recurrence_end >= ${query.from}`)
368
+ }
369
+ if (query.to) {
370
+ q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)
371
+ }
372
+ } else {
373
+ if (query.from) {
374
+ q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) >= ${query.from}`)
375
+ }
376
+ if (query.to) {
377
+ q = q.where(sql<boolean>`coalesce(occurred_at, scheduled_at, created_at) <= ${query.to}`)
378
+ }
368
379
  }
369
380
  return q
370
381
  }
@@ -228,15 +228,15 @@ export function CalendarScreen({ resourcesEnabled = false, staffEnabled = true }
228
228
 
229
229
  const typeOptions = React.useMemo(() => {
230
230
  const values = new Set<string>(Object.keys(typeLabels))
231
- for (const item of items) values.add(item.interactionType)
231
+ for (const item of visibleItems) values.add(item.interactionType)
232
232
  return [...values]
233
233
  .sort((a, b) => a.localeCompare(b))
234
234
  .map((value) => ({ value, label: typeLabels[value] ?? value }))
235
- }, [items, typeLabels])
235
+ }, [visibleItems, typeLabels])
236
236
 
237
237
  const ownerOptions = React.useMemo(() => {
238
238
  const participantNames = new Map<string, string>()
239
- for (const item of items) {
239
+ for (const item of visibleItems) {
240
240
  for (const participant of item.participants) {
241
241
  if (participant.name && !participantNames.has(participant.userId)) {
242
242
  participantNames.set(participant.userId, participant.name)
@@ -244,14 +244,14 @@ export function CalendarScreen({ resourcesEnabled = false, staffEnabled = true }
244
244
  }
245
245
  }
246
246
  const owners = new Map<string, string>()
247
- for (const item of items) {
247
+ for (const item of visibleItems) {
248
248
  if (!item.ownerUserId || owners.has(item.ownerUserId)) continue
249
249
  owners.set(item.ownerUserId, participantNames.get(item.ownerUserId) ?? item.ownerUserId)
250
250
  }
251
251
  return [...owners.entries()]
252
252
  .map(([value, label]) => ({ value, label }))
253
253
  .sort((first, second) => first.label.localeCompare(second.label))
254
- }, [items])
254
+ }, [visibleItems])
255
255
 
256
256
  const timezoneLabel = React.useMemo(() => buildTimezoneLabel(), [])
257
257
 
@@ -11,7 +11,7 @@ import { mapInteractionToCalendarItem } from '../../../lib/calendar/mapItem'
11
11
  import { expandOccurrences } from '../../../lib/calendar/recurrence'
12
12
  import { getFetchWindow } from '../../../lib/calendar/range'
13
13
  import type { CalendarItem } from '../types'
14
- import { fetchInteractionWindow } from '../useCalendarItems'
14
+ import { fetchCalendarCandidates } from '../useCalendarItems'
15
15
  import { fetchDealById, fetchRelatedEntityById, findStaffMemberName } from './lookups'
16
16
 
17
17
  // Edit-mode prefill stores ids only (parseItemToFormState is pure); resolve the
@@ -118,7 +118,7 @@ export function useConflictProbe(
118
118
  let active = true
119
119
  const timer = setTimeout(async () => {
120
120
  try {
121
- const { payloads } = await fetchInteractionWindow(fetchWindow, controller.signal)
121
+ const { payloads } = await fetchCalendarCandidates(fetchWindow, controller.signal)
122
122
  if (!active) return
123
123
  const others: CalendarItem[] = []
124
124
  for (const payload of payloads) {
@@ -15,6 +15,10 @@ import {
15
15
  const PAGE_LIMIT = 100
16
16
  export const MAX_WINDOW_ITEMS = 500
17
17
 
18
+ type FetchInteractionWindowOptions = {
19
+ recurrenceMasters?: boolean
20
+ }
21
+
18
22
  /**
19
23
  * Cursor-follows `/api/customers/interactions` across the given window (already
20
24
  * padded by the caller) up to `MAX_WINDOW_ITEMS`. Shared by the grid data hook
@@ -23,6 +27,7 @@ export const MAX_WINDOW_ITEMS = 500
23
27
  export async function fetchInteractionWindow(
24
28
  window: CalendarRange,
25
29
  signal?: AbortSignal,
30
+ options: FetchInteractionWindowOptions = {},
26
31
  ): Promise<{ payloads: CalendarInteractionPayload[]; truncated: boolean }> {
27
32
  const collected: CalendarInteractionPayload[] = []
28
33
  let cursor: string | undefined
@@ -33,6 +38,7 @@ export async function fetchInteractionWindow(
33
38
  to: window.to.toISOString(),
34
39
  limit: String(PAGE_LIMIT),
35
40
  })
41
+ if (options.recurrenceMasters) params.set('recurrenceMasters', 'true')
36
42
  if (cursor) params.set('cursor', cursor)
37
43
  const call = await apiCall<{ items?: unknown[]; nextCursor?: string }>(
38
44
  `/api/customers/interactions?${params.toString()}`,
@@ -53,6 +59,40 @@ export async function fetchInteractionWindow(
53
59
  return { payloads: collected.slice(0, MAX_WINDOW_ITEMS), truncated }
54
60
  }
55
61
 
62
+ export function mergeInteractionPayloads(
63
+ windowPayloads: CalendarInteractionPayload[],
64
+ recurringPayloads: CalendarInteractionPayload[],
65
+ ): { payloads: CalendarInteractionPayload[]; truncated: boolean } {
66
+ const windowById = new Map(windowPayloads.map((payload) => [payload.id, payload]))
67
+ const byId = new Map<string, CalendarInteractionPayload>()
68
+ for (const payload of recurringPayloads) {
69
+ byId.set(payload.id, windowById.get(payload.id) ?? payload)
70
+ }
71
+ for (const payload of windowPayloads) {
72
+ if (!byId.has(payload.id)) byId.set(payload.id, payload)
73
+ }
74
+ const payloads = Array.from(byId.values())
75
+ return {
76
+ payloads: payloads.slice(0, MAX_WINDOW_ITEMS),
77
+ truncated: payloads.length > MAX_WINDOW_ITEMS,
78
+ }
79
+ }
80
+
81
+ export async function fetchCalendarCandidates(
82
+ window: CalendarRange,
83
+ signal?: AbortSignal,
84
+ ): Promise<{ payloads: CalendarInteractionPayload[]; truncated: boolean }> {
85
+ const [windowResult, recurringResult] = await Promise.all([
86
+ fetchInteractionWindow(window, signal),
87
+ fetchInteractionWindow(window, signal, { recurrenceMasters: true }),
88
+ ])
89
+ const merged = mergeInteractionPayloads(windowResult.payloads, recurringResult.payloads)
90
+ return {
91
+ payloads: merged.payloads,
92
+ truncated: windowResult.truncated || recurringResult.truncated || merged.truncated,
93
+ }
94
+ }
95
+
56
96
  type ActivityTypeDictionaryEntry = {
57
97
  value?: unknown
58
98
  label?: unknown
@@ -127,13 +167,10 @@ export function useCalendarItems(range: CalendarRange): UseCalendarItemsResult {
127
167
  setError(null)
128
168
  try {
129
169
  const fetchWindow = getFetchWindow({ from: new Date(fromTime), to: new Date(toTime) })
130
- const { payloads: collected, truncated: windowTruncated } = await fetchInteractionWindow(
131
- fetchWindow,
132
- controller.signal,
133
- )
170
+ const result = await fetchCalendarCandidates(fetchWindow, controller.signal)
134
171
  if (cancelled) return
135
- setPayloads(collected)
136
- setTruncated(windowTruncated)
172
+ setPayloads(result.payloads)
173
+ setTruncated(result.truncated)
137
174
  } catch (err) {
138
175
  if (cancelled || controller.signal.aborted) return
139
176
  setPayloads([])