@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/authFixtures.js +3 -0
- package/dist/helpers/integration/authFixtures.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +15 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/consents/route.js +8 -1
- package/dist/modules/auth/api/users/consents/route.js.map +2 -2
- package/dist/modules/auth/api/users/resend-invite/route.js +8 -1
- package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +80 -6
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/commands/users.js +49 -2
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +86 -5
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js +19 -11
- package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuthServer.js +14 -7
- package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerSessionService.js +14 -0
- package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +16 -5
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
- package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
- package/dist/modules/customers/components/calendar/useCalendarItems.js +33 -7
- package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/authFixtures.ts +5 -2
- package/src/modules/auth/api/users/acl/route.ts +13 -0
- package/src/modules/auth/api/users/consents/route.ts +7 -0
- package/src/modules/auth/api/users/resend-invite/route.ts +7 -0
- package/src/modules/auth/api/users/route.ts +80 -4
- package/src/modules/auth/commands/users.ts +54 -2
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/auth/lib/grantChecks.ts +121 -5
- package/src/modules/auth/lib/sessionIntegrity.ts +5 -4
- package/src/modules/customer_accounts/lib/customerAuth.ts +31 -12
- package/src/modules/customer_accounts/lib/customerAuthServer.ts +19 -7
- package/src/modules/customer_accounts/services/customerSessionService.ts +20 -0
- package/src/modules/customers/api/interactions/route.ts +16 -5
- package/src/modules/customers/components/calendar/CalendarScreen.tsx +5 -5
- package/src/modules/customers/components/calendar/editor/hooks.ts +2 -2
- 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:
|
|
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
|
-
|
|
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 || !
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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(
|
|
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.
|
|
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:
|
|
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
|
-
|
|
114
|
-
if (
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
151
|
+
sub: userId,
|
|
133
152
|
sid,
|
|
134
153
|
type: 'customer',
|
|
135
|
-
tenantId
|
|
136
|
-
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(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
85
|
+
userId,
|
|
74
86
|
tenantId,
|
|
75
|
-
|
|
87
|
+
organizationId,
|
|
76
88
|
payload.iat,
|
|
77
89
|
)
|
|
78
90
|
if (!userState.valid) return null
|
|
79
91
|
|
|
80
92
|
return {
|
|
81
|
-
sub:
|
|
93
|
+
sub: userId,
|
|
82
94
|
sid,
|
|
83
95
|
type: 'customer',
|
|
84
96
|
tenantId,
|
|
85
|
-
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.
|
|
364
|
-
q = q.where(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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
|
-
}, [
|
|
235
|
+
}, [visibleItems, typeLabels])
|
|
236
236
|
|
|
237
237
|
const ownerOptions = React.useMemo(() => {
|
|
238
238
|
const participantNames = new Map<string, string>()
|
|
239
|
-
for (const item of
|
|
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
|
|
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
|
-
}, [
|
|
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 {
|
|
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
|
|
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
|
|
131
|
-
fetchWindow,
|
|
132
|
-
controller.signal,
|
|
133
|
-
)
|
|
170
|
+
const result = await fetchCalendarCandidates(fetchWindow, controller.signal)
|
|
134
171
|
if (cancelled) return
|
|
135
|
-
setPayloads(
|
|
136
|
-
setTruncated(
|
|
172
|
+
setPayloads(result.payloads)
|
|
173
|
+
setTruncated(result.truncated)
|
|
137
174
|
} catch (err) {
|
|
138
175
|
if (cancelled || controller.signal.aborted) return
|
|
139
176
|
setPayloads([])
|