@open-mercato/core 0.7.1-develop.7154.1.981330c924 → 0.7.1-develop.7171.1.9b31dbba45
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/AGENTS.md +2 -2
- package/dist/modules/auth/commands/users.js +14 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +26 -3
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/profile-sections.js +1 -1
- package/dist/modules/auth/lib/profile-sections.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
- package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
- package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
- package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
- package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
- package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/data/entities.js +4 -1
- package/dist/modules/customer_accounts/data/entities.js.map +2 -2
- package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
- package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
- package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
- package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
- package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
- package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
- package/dist/modules/customer_accounts/setup.js +2 -6
- package/dist/modules/customer_accounts/setup.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customers/commands/people.js +16 -1
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -15
- package/dist/modules/entities/api/records.js.map +3 -3
- package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
- package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
- package/dist/modules/entities/lib/validation.js +4 -35
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
- package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
- package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
- package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
- package/dist/modules/wms/commands/sales-order-assignment.js +12 -0
- package/dist/modules/wms/commands/sales-order-assignment.js.map +2 -2
- package/dist/modules/wms/data/enrichers.js +25 -0
- package/dist/modules/wms/data/enrichers.js.map +2 -2
- package/dist/modules/wms/lib/enricherCacheTags.js +9 -0
- package/dist/modules/wms/lib/enricherCacheTags.js.map +7 -0
- package/dist/modules/wms/lib/invalidateInventoryEnricherCache.js +37 -0
- package/dist/modules/wms/lib/invalidateInventoryEnricherCache.js.map +7 -0
- package/dist/modules/wms/lib/inventoryEnricherCacheSubscriber.js +14 -0
- package/dist/modules/wms/lib/inventoryEnricherCacheSubscriber.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-balance.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-balance.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-inventory.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-inventory.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-movement.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-movement.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-profile.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-profile.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-reservation.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-reservation.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-line.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-line.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-order.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-order.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-variant.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-variant.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-warehouse.js +12 -0
- package/dist/modules/wms/subscribers/invalidate-enricher-cache-warehouse.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/commands/users.ts +14 -1
- package/src/modules/auth/data/entities.ts +2 -2
- package/src/modules/auth/lib/backendChrome.tsx +46 -2
- package/src/modules/auth/lib/profile-sections.tsx +14 -3
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
- package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
- package/src/modules/customer_accounts/AGENTS.md +3 -1
- package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
- package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
- package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
- package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
- package/src/modules/customer_accounts/data/entities.ts +5 -1
- package/src/modules/customer_accounts/i18n/de.json +4 -2
- package/src/modules/customer_accounts/i18n/en.json +4 -2
- package/src/modules/customer_accounts/i18n/es.json +4 -2
- package/src/modules/customer_accounts/i18n/ko.json +4 -2
- package/src/modules/customer_accounts/i18n/pl.json +4 -2
- package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
- package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
- package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
- package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
- package/src/modules/customer_accounts/setup.ts +2 -6
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
- package/src/modules/customers/commands/people.ts +15 -0
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
- package/src/modules/entities/api/records.ts +24 -15
- package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
- package/src/modules/entities/lib/validation.ts +5 -45
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
- package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
- package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
- package/src/modules/progress/AGENTS.md +1 -1
- package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
- package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
- package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
- package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
- package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
- package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/ko.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/wms/commands/sales-order-assignment.ts +20 -0
- package/src/modules/wms/data/enrichers.ts +25 -0
- package/src/modules/wms/lib/enricherCacheTags.ts +29 -0
- package/src/modules/wms/lib/invalidateInventoryEnricherCache.ts +71 -0
- package/src/modules/wms/lib/inventoryEnricherCacheSubscriber.ts +30 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-balance.ts +9 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-inventory.ts +9 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-movement.ts +9 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-profile.ts +9 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-reservation.ts +9 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-sales-line.ts +14 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-sales-order.ts +18 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-variant.ts +9 -0
- package/src/modules/wms/subscribers/invalidate-enricher-cache-warehouse.ts +9 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UniqueConstraintViolationException } from '@mikro-orm/core'
|
|
1
2
|
import { EntityManager } from '@mikro-orm/postgresql'
|
|
2
3
|
import { hash } from 'bcryptjs'
|
|
3
4
|
import {
|
|
@@ -7,12 +8,57 @@ import {
|
|
|
7
8
|
CustomerRole,
|
|
8
9
|
} from '@open-mercato/core/modules/customer_accounts/data/entities'
|
|
9
10
|
import { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'
|
|
10
|
-
import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'
|
|
11
|
+
import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
11
12
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
12
13
|
|
|
13
14
|
const BCRYPT_COST = 10
|
|
14
15
|
const INVITATION_TTL_MS = 72 * 60 * 60 * 1000 // 72 hours
|
|
15
16
|
|
|
17
|
+
// Use Symbol.for so the marker survives module duplication across bundle
|
|
18
|
+
// boundaries: the accept route and this service are bundled into separate
|
|
19
|
+
// chunks, so `instanceof` between them is false and the 409 mapping would
|
|
20
|
+
// silently degrade back into the raw 500 this error exists to prevent.
|
|
21
|
+
const CUSTOMER_INVITATION_EMAIL_CONFLICT_MARKER = Symbol.for('@open-mercato/CustomerInvitationEmailConflictError')
|
|
22
|
+
|
|
23
|
+
export class CustomerInvitationEmailConflictError extends Error {
|
|
24
|
+
readonly [CUSTOMER_INVITATION_EMAIL_CONFLICT_MARKER] = true
|
|
25
|
+
|
|
26
|
+
constructor(public readonly email: string) {
|
|
27
|
+
super('[internal] An account with this email address already exists')
|
|
28
|
+
this.name = 'CustomerInvitationEmailConflictError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bundle-safe check for {@link CustomerInvitationEmailConflictError}. Always prefer
|
|
34
|
+
* this over `instanceof` — API routes reach this service through DI and resolve a
|
|
35
|
+
* different copy of this module.
|
|
36
|
+
*/
|
|
37
|
+
export function isCustomerInvitationEmailConflictError(
|
|
38
|
+
error: unknown,
|
|
39
|
+
): error is CustomerInvitationEmailConflictError {
|
|
40
|
+
return !!error
|
|
41
|
+
&& typeof error === 'object'
|
|
42
|
+
&& (error as Record<symbol, unknown>)[CUSTOMER_INVITATION_EMAIL_CONFLICT_MARKER] === true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const POSTGRES_UNIQUE_VIOLATION = '23505'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Unique-constraint detection that does not depend on `instanceof`: the MikroORM
|
|
49
|
+
* copy this module is bundled with is not necessarily the one the driver throws
|
|
50
|
+
* from, so fall back to the exception name and the Postgres SQLSTATE.
|
|
51
|
+
*/
|
|
52
|
+
function isUniqueConstraintViolation(error: unknown): boolean {
|
|
53
|
+
if (error instanceof UniqueConstraintViolationException) return true
|
|
54
|
+
if (!error || typeof error !== 'object') return false
|
|
55
|
+
const candidate = error as { name?: unknown; code?: unknown; cause?: unknown }
|
|
56
|
+
if (candidate.name === 'UniqueConstraintViolationException') return true
|
|
57
|
+
if (candidate.code === POSTGRES_UNIQUE_VIOLATION) return true
|
|
58
|
+
const cause = candidate.cause as { code?: unknown } | undefined
|
|
59
|
+
return !!cause && typeof cause === 'object' && cause.code === POSTGRES_UNIQUE_VIOLATION
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
export type CustomerInvitationRollbackState = {
|
|
17
63
|
email: string
|
|
18
64
|
token: string
|
|
@@ -85,11 +131,16 @@ export class CustomerInvitationService {
|
|
|
85
131
|
existing.email = normalizedEmail
|
|
86
132
|
existing.token = tokenHashed
|
|
87
133
|
existing.customerEntityId = options.customerEntityId || null
|
|
88
|
-
|
|
134
|
+
// Keep the CRM person link and the recipient's display name when the
|
|
135
|
+
// re-invite does not carry them: the portal invite route and the admin users
|
|
136
|
+
// page only ever know the company, so overwriting with null would strip an
|
|
137
|
+
// invitation raised from a person card off that person's account-status card
|
|
138
|
+
// and drop the personalization from the invitation email (#5499).
|
|
139
|
+
existing.personEntityId = options.personEntityId || existing.personEntityId || null
|
|
89
140
|
existing.roleIdsJson = options.roleIds
|
|
90
141
|
existing.invitedByUserId = options.invitedByUserId || null
|
|
91
142
|
existing.invitedByCustomerUserId = options.invitedByCustomerUserId || null
|
|
92
|
-
existing.displayName = options.displayName || null
|
|
143
|
+
existing.displayName = options.displayName || existing.displayName || null
|
|
93
144
|
existing.expiresAt = expiresAt
|
|
94
145
|
await this.em.flush()
|
|
95
146
|
return { invitation: existing, rawToken: token, reused: true, rollbackState }
|
|
@@ -157,9 +208,31 @@ export class CustomerInvitationService {
|
|
|
157
208
|
const invitation = await this.findByToken(token)
|
|
158
209
|
if (!invitation) return null
|
|
159
210
|
|
|
160
|
-
const passwordHash = await hash(password, BCRYPT_COST)
|
|
161
211
|
const emailHash = hashForLookup(invitation.email)
|
|
162
212
|
|
|
213
|
+
// A soft-deleted CustomerUser row does not block re-invitation (the unique
|
|
214
|
+
// index only applies to non-deleted rows), but an active account with the
|
|
215
|
+
// same email is a genuine conflict — fail with a domain error here instead
|
|
216
|
+
// of letting the DB unique-constraint violation surface as a raw 500.
|
|
217
|
+
// The lookup matches both hash formats: rows written before the keyed digest
|
|
218
|
+
// still carry the legacy hash, and the partial index cannot relate the two.
|
|
219
|
+
const existingActiveUser = await findOneWithDecryption(
|
|
220
|
+
this.em,
|
|
221
|
+
CustomerUser,
|
|
222
|
+
{
|
|
223
|
+
tenantId: invitation.tenantId,
|
|
224
|
+
emailHash: { $in: lookupHashCandidates(invitation.email) },
|
|
225
|
+
deletedAt: null,
|
|
226
|
+
} as any,
|
|
227
|
+
undefined,
|
|
228
|
+
{ tenantId: invitation.tenantId, organizationId: invitation.organizationId },
|
|
229
|
+
)
|
|
230
|
+
if (existingActiveUser) {
|
|
231
|
+
throw new CustomerInvitationEmailConflictError(invitation.email)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const passwordHash = await hash(password, BCRYPT_COST)
|
|
235
|
+
|
|
163
236
|
// Create user
|
|
164
237
|
const user = this.em.create(CustomerUser, {
|
|
165
238
|
email: invitation.email,
|
|
@@ -205,7 +278,18 @@ export class CustomerInvitationService {
|
|
|
205
278
|
// Mark invitation as accepted
|
|
206
279
|
invitation.acceptedAt = new Date()
|
|
207
280
|
|
|
208
|
-
|
|
281
|
+
// The guard above is only the friendly fast path: it and this write are
|
|
282
|
+
// separated by a bcrypt hash and a role query, so two concurrent accepts can
|
|
283
|
+
// both pass it. The partial unique index is the real authority — translate
|
|
284
|
+
// its violation onto the same domain error so both paths answer 409.
|
|
285
|
+
try {
|
|
286
|
+
await this.em.flush()
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (isUniqueConstraintViolation(error)) {
|
|
289
|
+
throw new CustomerInvitationEmailConflictError(invitation.email)
|
|
290
|
+
}
|
|
291
|
+
throw error
|
|
292
|
+
}
|
|
209
293
|
return { user, invitation }
|
|
210
294
|
}
|
|
211
295
|
}
|
|
@@ -3,6 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
3
3
|
import type { Module } from '@open-mercato/shared/modules/registry'
|
|
4
4
|
import { hash } from 'bcryptjs'
|
|
5
5
|
import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'
|
|
6
|
+
import { EXAMPLE_PORTAL_ACCOUNTS } from '@open-mercato/core/modules/customer_accounts/lib/exampleAccounts'
|
|
6
7
|
import {
|
|
7
8
|
CustomerRole,
|
|
8
9
|
CustomerRoleAcl,
|
|
@@ -256,13 +257,8 @@ export const setup: ModuleSetupConfig = {
|
|
|
256
257
|
|
|
257
258
|
async seedExamples({ em, tenantId, organizationId }) {
|
|
258
259
|
const BCRYPT_COST = 10
|
|
259
|
-
const exampleUsers = [
|
|
260
|
-
{ email: 'alice.johnson@example.com', displayName: 'Alice Johnson', password: 'Password123!', roleSlug: 'portal_admin' },
|
|
261
|
-
{ email: 'bob.smith@example.com', displayName: 'Bob Smith', password: 'Password123!', roleSlug: 'buyer' },
|
|
262
|
-
{ email: 'carol.white@example.com', displayName: 'Carol White', password: 'Password123!', roleSlug: 'viewer' },
|
|
263
|
-
]
|
|
264
260
|
|
|
265
|
-
for (const entry of
|
|
261
|
+
for (const entry of EXAMPLE_PORTAL_ACCOUNTS) {
|
|
266
262
|
const emailHash = hashForLookup(entry.email)
|
|
267
263
|
const existing = await em.findOne(CustomerUser, { emailHash, tenantId, deletedAt: null })
|
|
268
264
|
if (existing) continue
|
|
@@ -48,9 +48,26 @@ interface PersonData {
|
|
|
48
48
|
} | null
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
async function fetchPendingInvitation(filter: string): Promise<PendingInvitationData | null> {
|
|
52
|
+
const result = await apiCall(`/api/customer_accounts/admin/users-invite?${filter}&pageSize=1`)
|
|
53
|
+
if (!result.ok) return null
|
|
54
|
+
const json = result.result as Record<string, unknown> | null
|
|
55
|
+
const items = json?.items as PendingInvitationData[] | undefined
|
|
56
|
+
return items?.[0] || null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function InviteForm({
|
|
60
|
+
personEntityId,
|
|
61
|
+
personData,
|
|
62
|
+
isLoadingPerson,
|
|
63
|
+
onSuccess,
|
|
64
|
+
}: {
|
|
65
|
+
personEntityId: string
|
|
66
|
+
personData: PersonData | null
|
|
67
|
+
isLoadingPerson: boolean
|
|
68
|
+
onSuccess: () => void
|
|
69
|
+
}) {
|
|
52
70
|
const t = useT()
|
|
53
|
-
const [isLoadingPerson, setIsLoadingPerson] = React.useState(true)
|
|
54
71
|
const [email, setEmail] = React.useState('')
|
|
55
72
|
const [displayName, setDisplayName] = React.useState('')
|
|
56
73
|
const [selectedRoleIds, setSelectedRoleIds] = React.useState<string[]>([])
|
|
@@ -63,35 +80,19 @@ function InviteForm({ personEntityId, onSuccess }: { personEntityId: string; onS
|
|
|
63
80
|
})
|
|
64
81
|
|
|
65
82
|
React.useEffect(() => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
72
|
-
if (cancelled) return
|
|
73
|
-
if (call.ok && call.result) {
|
|
74
|
-
const person = call.result.person
|
|
75
|
-
const profile = call.result.profile
|
|
76
|
-
if (person?.primaryEmail) {
|
|
77
|
-
setEmail(person.primaryEmail)
|
|
78
|
-
}
|
|
79
|
-
const nameParts = [profile?.firstName, profile?.lastName].filter(Boolean)
|
|
80
|
-
if (nameParts.length > 0) {
|
|
81
|
-
setDisplayName(nameParts.join(' '))
|
|
82
|
-
} else if (person?.displayName) {
|
|
83
|
-
setDisplayName(person.displayName)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
/* ignore - fields will remain empty for manual entry */
|
|
88
|
-
} finally {
|
|
89
|
-
if (!cancelled) setIsLoadingPerson(false)
|
|
90
|
-
}
|
|
83
|
+
if (!personData) return
|
|
84
|
+
const person = personData.person
|
|
85
|
+
const profile = personData.profile
|
|
86
|
+
if (person?.primaryEmail) {
|
|
87
|
+
setEmail(person.primaryEmail)
|
|
91
88
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
const nameParts = [profile?.firstName, profile?.lastName].filter(Boolean)
|
|
90
|
+
if (nameParts.length > 0) {
|
|
91
|
+
setDisplayName(nameParts.join(' '))
|
|
92
|
+
} else if (person?.displayName) {
|
|
93
|
+
setDisplayName(person.displayName)
|
|
94
|
+
}
|
|
95
|
+
}, [personData])
|
|
95
96
|
|
|
96
97
|
React.useEffect(() => {
|
|
97
98
|
let cancelled = false
|
|
@@ -272,25 +273,52 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
272
273
|
enabled: !!personEntityId,
|
|
273
274
|
})
|
|
274
275
|
|
|
276
|
+
// The person record backs both the pending-invitation email fallback below and
|
|
277
|
+
// the invite form defaults, so it is fetched once here and shared.
|
|
278
|
+
const { data: personData, isLoading: isLoadingPerson } = useQuery({
|
|
279
|
+
queryKey: ['customer-account-person', personEntityId],
|
|
280
|
+
queryFn: async (): Promise<PersonData | null> => {
|
|
281
|
+
if (!personEntityId) return null
|
|
282
|
+
const result = await apiCall<PersonData>(
|
|
283
|
+
`/api/customers/people/${encodeURIComponent(personEntityId)}`,
|
|
284
|
+
)
|
|
285
|
+
if (!result.ok) return null
|
|
286
|
+
return (result.result as PersonData | null) || null
|
|
287
|
+
},
|
|
288
|
+
enabled: !!personEntityId && !isLoading && !data,
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const personEmail = personData?.person?.primaryEmail?.trim() || null
|
|
292
|
+
|
|
275
293
|
// A portal account only exists once the invitation is accepted, so the users
|
|
276
294
|
// query above stays empty right after a successful invite. Without this the
|
|
277
295
|
// widget renders the identical "no account" state and the invite looks like a
|
|
278
296
|
// no-op (#4950).
|
|
279
297
|
const { data: pendingInvitation, isLoading: isLoadingInvitation } = useQuery({
|
|
280
|
-
queryKey: ['customer-account-pending-invitation', personEntityId],
|
|
298
|
+
queryKey: ['customer-account-pending-invitation', personEntityId, personEmail],
|
|
281
299
|
queryFn: async (): Promise<PendingInvitationData | null> => {
|
|
282
300
|
if (!personEntityId) return null
|
|
283
|
-
const
|
|
284
|
-
|
|
301
|
+
const byPerson = await fetchPendingInvitation(
|
|
302
|
+
`personEntityId=${encodeURIComponent(personEntityId)}`,
|
|
285
303
|
)
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
304
|
+
if (byPerson) return byPerson
|
|
305
|
+
// person_entity_id is optional on an invitation: the portal invite route
|
|
306
|
+
// only ever knows the company, and rows written before the entity-ownership
|
|
307
|
+
// guard landed the person id in customer_entity_id instead. The recipient
|
|
308
|
+
// address is the one identity every invitation carries, so match on it
|
|
309
|
+
// before reporting "no account" for someone who was already invited (#5499).
|
|
310
|
+
if (!personEmail) return null
|
|
311
|
+
return fetchPendingInvitation(`email=${encodeURIComponent(personEmail)}`)
|
|
290
312
|
},
|
|
291
|
-
enabled: !!personEntityId && !isLoading && !data,
|
|
313
|
+
enabled: !!personEntityId && !isLoading && !data && !isLoadingPerson,
|
|
292
314
|
})
|
|
293
315
|
|
|
316
|
+
// A disabled React Query reports isLoading === false, so isLoadingInvitation is
|
|
317
|
+
// false for the whole person fetch the email fallback waits on. Rendering off
|
|
318
|
+
// that alone would show "no portal account linked" with a live invite button
|
|
319
|
+
// during that window — the double-invite #5499 exists to prevent.
|
|
320
|
+
const isResolvingInvitation = isLoadingPerson || isLoadingInvitation
|
|
321
|
+
|
|
294
322
|
function handleInviteSuccess() {
|
|
295
323
|
setShowInviteForm(false)
|
|
296
324
|
queryClient.invalidateQueries({ queryKey: ['customer-account-status', personEntityId] })
|
|
@@ -305,7 +333,7 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
305
333
|
return (
|
|
306
334
|
<div className="rounded-md border p-3">
|
|
307
335
|
<div className="text-sm font-medium mb-1">{t('customer_accounts.widgets.accountStatus', 'Portal Account')}</div>
|
|
308
|
-
{
|
|
336
|
+
{isResolvingInvitation ? (
|
|
309
337
|
<div className="text-sm text-muted-foreground">{t('common.loading', 'Loading...')}</div>
|
|
310
338
|
) : pendingInvitation ? (
|
|
311
339
|
<div className="space-y-1 text-sm">
|
|
@@ -327,7 +355,7 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
327
355
|
) : (
|
|
328
356
|
<div className="text-sm text-muted-foreground">{t('customer_accounts.widgets.noAccount', 'No portal account linked')}</div>
|
|
329
357
|
)}
|
|
330
|
-
{!showInviteForm && personEntityId && !
|
|
358
|
+
{!showInviteForm && personEntityId && !isResolvingInvitation && (
|
|
331
359
|
<div className="mt-2">
|
|
332
360
|
<Button
|
|
333
361
|
type="button"
|
|
@@ -342,7 +370,12 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
|
|
|
342
370
|
</div>
|
|
343
371
|
)}
|
|
344
372
|
{showInviteForm && personEntityId && (
|
|
345
|
-
<InviteForm
|
|
373
|
+
<InviteForm
|
|
374
|
+
personEntityId={personEntityId}
|
|
375
|
+
personData={personData ?? null}
|
|
376
|
+
isLoadingPerson={isLoadingPerson}
|
|
377
|
+
onSuccess={handleInviteSuccess}
|
|
378
|
+
/>
|
|
346
379
|
)}
|
|
347
380
|
</div>
|
|
348
381
|
)
|
|
@@ -207,6 +207,7 @@ const personCrudEvents: CrudEventsConfig<CustomerEntity> = {
|
|
|
207
207
|
entityId: ctx.entity?.id ?? ctx.identifiers.id,
|
|
208
208
|
organizationId: ctx.identifiers.organizationId,
|
|
209
209
|
tenantId: ctx.identifiers.tenantId,
|
|
210
|
+
...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
|
|
210
211
|
}),
|
|
211
212
|
}
|
|
212
213
|
|
|
@@ -723,6 +724,8 @@ const createPersonCommand: CommandHandler<PersonCreateInput, { entityId: string;
|
|
|
723
724
|
tenantId,
|
|
724
725
|
organizationId,
|
|
725
726
|
},
|
|
727
|
+
syncOrigin: ctx.syncOrigin,
|
|
728
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
726
729
|
indexer: personCrudIndexer,
|
|
727
730
|
events: personCrudEvents,
|
|
728
731
|
})
|
|
@@ -784,6 +787,8 @@ const createPersonCommand: CommandHandler<PersonCreateInput, { entityId: string;
|
|
|
784
787
|
action: 'deleted',
|
|
785
788
|
entity,
|
|
786
789
|
identifiers,
|
|
790
|
+
syncOrigin: ctx.syncOrigin,
|
|
791
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
787
792
|
indexer: personCrudIndexer,
|
|
788
793
|
events: personCrudEvents,
|
|
789
794
|
})
|
|
@@ -904,6 +909,8 @@ const createPersonCommand: CommandHandler<PersonCreateInput, { entityId: string;
|
|
|
904
909
|
tenantId: restoredEntity.tenantId,
|
|
905
910
|
organizationId: restoredEntity.organizationId,
|
|
906
911
|
},
|
|
912
|
+
syncOrigin: ctx.syncOrigin,
|
|
913
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
907
914
|
indexer: personCrudIndexer,
|
|
908
915
|
events: personCrudEvents,
|
|
909
916
|
})
|
|
@@ -1057,6 +1064,8 @@ const updatePersonCommand: CommandHandler<PersonUpdateInput, { entityId: string
|
|
|
1057
1064
|
tenantId: record.tenantId,
|
|
1058
1065
|
organizationId: record.organizationId,
|
|
1059
1066
|
},
|
|
1067
|
+
syncOrigin: ctx.syncOrigin,
|
|
1068
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
1060
1069
|
indexer: personCrudIndexer,
|
|
1061
1070
|
events: personCrudEvents,
|
|
1062
1071
|
})
|
|
@@ -1199,6 +1208,8 @@ const updatePersonCommand: CommandHandler<PersonUpdateInput, { entityId: string
|
|
|
1199
1208
|
organizationId: before.entity.organizationId,
|
|
1200
1209
|
tenantId: before.entity.tenantId,
|
|
1201
1210
|
},
|
|
1211
|
+
syncOrigin: ctx.syncOrigin,
|
|
1212
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
1202
1213
|
indexer: personCrudIndexer,
|
|
1203
1214
|
events: personCrudEvents,
|
|
1204
1215
|
})
|
|
@@ -1322,6 +1333,8 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
1322
1333
|
organizationId: record.organizationId,
|
|
1323
1334
|
tenantId: record.tenantId,
|
|
1324
1335
|
},
|
|
1336
|
+
syncOrigin: ctx.syncOrigin,
|
|
1337
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
1325
1338
|
indexer: personCrudIndexer,
|
|
1326
1339
|
events: personCrudEvents,
|
|
1327
1340
|
})
|
|
@@ -1677,6 +1690,8 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
1677
1690
|
organizationId: entity.organizationId,
|
|
1678
1691
|
tenantId: entity.tenantId,
|
|
1679
1692
|
},
|
|
1693
|
+
syncOrigin: ctx.syncOrigin,
|
|
1694
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
1680
1695
|
indexer: personCrudIndexer,
|
|
1681
1696
|
events: personCrudEvents,
|
|
1682
1697
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import {
|
|
3
|
+
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
5
5
|
import { FormHeader } from '@open-mercato/ui/backend/forms'
|
|
6
6
|
import { Card, CardHeader, CardTitle, CardContent } from '@open-mercato/ui/primitives/card'
|
|
@@ -87,28 +87,15 @@ function formatEtaSeconds(seconds: number): string {
|
|
|
87
87
|
|
|
88
88
|
type SyncRunDetailPageProps = {
|
|
89
89
|
params?: {
|
|
90
|
-
id?: string
|
|
90
|
+
id?: string
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function resolveRouteId(value: string | string[] | undefined): string | undefined {
|
|
95
|
-
if (Array.isArray(value)) return value[0]
|
|
96
|
-
return value
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function resolvePathnameId(pathname: string): string | undefined {
|
|
100
|
-
const parts = pathname.split('/').filter(Boolean)
|
|
101
|
-
const runId = parts.at(-1)
|
|
102
|
-
if (!runId || runId === 'runs' || runId === 'data-sync') return undefined
|
|
103
|
-
return decodeURIComponent(runId)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
94
|
const LOG_PAGE_SIZE = 50
|
|
107
95
|
|
|
108
96
|
export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
109
|
-
const pathname = usePathname()
|
|
110
97
|
const router = useRouter()
|
|
111
|
-
const runId =
|
|
98
|
+
const runId = params?.id
|
|
112
99
|
const t = useT()
|
|
113
100
|
const { runMutation } = useGuardedMutation<Record<string, unknown>>({
|
|
114
101
|
contextId: 'data_sync.runDetail',
|
|
@@ -129,14 +116,6 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
129
116
|
// event that re-reads the run.
|
|
130
117
|
const parameterLabelsIntegrationRef = React.useRef<string | null>(null)
|
|
131
118
|
|
|
132
|
-
const resolveCurrentRunId = React.useCallback(() => {
|
|
133
|
-
return runId ?? (
|
|
134
|
-
typeof window !== 'undefined'
|
|
135
|
-
? resolvePathnameId(window.location.pathname)
|
|
136
|
-
: undefined
|
|
137
|
-
)
|
|
138
|
-
}, [runId])
|
|
139
|
-
|
|
140
119
|
// The run row stores machine keys. Resolve the adapter's declared labels so a
|
|
141
120
|
// past run reads as "Start id" rather than "startId"; keys the adapter no
|
|
142
121
|
// longer declares keep their raw form, which keeps historical runs readable.
|
|
@@ -158,15 +137,14 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
158
137
|
}, [t])
|
|
159
138
|
|
|
160
139
|
const loadRun = React.useCallback(async () => {
|
|
161
|
-
|
|
162
|
-
if (!currentRunId) {
|
|
140
|
+
if (!runId) {
|
|
163
141
|
setError(t('data_sync.runs.detail.loadError'))
|
|
164
142
|
setIsLoading(false)
|
|
165
143
|
return
|
|
166
144
|
}
|
|
167
145
|
setIsNotFound(false)
|
|
168
146
|
const call = await apiCall<SyncRunDetail>(
|
|
169
|
-
`/api/data_sync/runs/${encodeURIComponent(
|
|
147
|
+
`/api/data_sync/runs/${encodeURIComponent(runId)}`,
|
|
170
148
|
undefined,
|
|
171
149
|
{ fallback: null },
|
|
172
150
|
)
|
|
@@ -184,14 +162,13 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
184
162
|
if (call.result.parameters && Object.keys(call.result.parameters).length > 0) {
|
|
185
163
|
void loadParameterLabels(call.result.integrationId)
|
|
186
164
|
}
|
|
187
|
-
}, [loadParameterLabels,
|
|
165
|
+
}, [loadParameterLabels, runId, t])
|
|
188
166
|
|
|
189
167
|
const loadLogs = React.useCallback(async (page?: number) => {
|
|
190
|
-
|
|
191
|
-
if (!currentRunId) return
|
|
168
|
+
if (!runId) return
|
|
192
169
|
const targetPage = page ?? logsPageRef.current
|
|
193
170
|
setIsLoadingLogs(true)
|
|
194
|
-
const params = new URLSearchParams({ runId
|
|
171
|
+
const params = new URLSearchParams({ runId, pageSize: String(LOG_PAGE_SIZE), page: String(targetPage) })
|
|
195
172
|
const call = await apiCall<{ items: LogEntry[]; total?: number }>(
|
|
196
173
|
`/api/integrations/logs?${params.toString()}`,
|
|
197
174
|
undefined,
|
|
@@ -204,7 +181,7 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
204
181
|
setLogsPage(targetPage)
|
|
205
182
|
}
|
|
206
183
|
setIsLoadingLogs(false)
|
|
207
|
-
}, [
|
|
184
|
+
}, [runId])
|
|
208
185
|
|
|
209
186
|
React.useEffect(() => {
|
|
210
187
|
void loadRun()
|
|
@@ -265,18 +242,17 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
265
242
|
}, [loadLogs, loadRun])
|
|
266
243
|
|
|
267
244
|
const handleCancel = React.useCallback(async () => {
|
|
268
|
-
|
|
269
|
-
if (!currentRunId) return
|
|
245
|
+
if (!runId) return
|
|
270
246
|
const call = await runMutation({
|
|
271
247
|
// optimistic-lock-exempt: run lifecycle action endpoint (cancel), not a concurrent record edit
|
|
272
|
-
operation: () => apiCall(`/api/data_sync/runs/${encodeURIComponent(
|
|
248
|
+
operation: () => apiCall(`/api/data_sync/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
273
249
|
method: 'POST',
|
|
274
250
|
}, { fallback: null }),
|
|
275
|
-
mutationPayload: { runId
|
|
251
|
+
mutationPayload: { runId },
|
|
276
252
|
context: {
|
|
277
253
|
operation: 'update',
|
|
278
254
|
actionId: 'cancel-sync-run',
|
|
279
|
-
runId
|
|
255
|
+
runId,
|
|
280
256
|
},
|
|
281
257
|
})
|
|
282
258
|
if (call.ok) {
|
|
@@ -285,23 +261,22 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
285
261
|
} else {
|
|
286
262
|
flash(t('data_sync.runs.detail.cancelError'), 'error')
|
|
287
263
|
}
|
|
288
|
-
}, [
|
|
264
|
+
}, [runId, runMutation, t, loadRun])
|
|
289
265
|
|
|
290
266
|
const handleRetry = React.useCallback(async () => {
|
|
291
|
-
|
|
292
|
-
if (!currentRunId) return
|
|
267
|
+
if (!runId) return
|
|
293
268
|
const call = await runMutation({
|
|
294
269
|
// optimistic-lock-exempt: starts a new retry run (create), not a concurrent record edit
|
|
295
|
-
operation: () => apiCall<{ id: string }>(`/api/data_sync/runs/${encodeURIComponent(
|
|
270
|
+
operation: () => apiCall<{ id: string }>(`/api/data_sync/runs/${encodeURIComponent(runId)}/retry`, {
|
|
296
271
|
method: 'POST',
|
|
297
272
|
headers: { 'Content-Type': 'application/json' },
|
|
298
273
|
body: JSON.stringify({ fromBeginning: false }),
|
|
299
274
|
}, { fallback: null }),
|
|
300
|
-
mutationPayload: { runId
|
|
275
|
+
mutationPayload: { runId, fromBeginning: false },
|
|
301
276
|
context: {
|
|
302
277
|
operation: 'create',
|
|
303
278
|
actionId: 'retry-sync-run',
|
|
304
|
-
runId
|
|
279
|
+
runId,
|
|
305
280
|
},
|
|
306
281
|
})
|
|
307
282
|
if (call.ok && call.result) {
|
|
@@ -310,7 +285,7 @@ export default function SyncRunDetailPage({ params }: SyncRunDetailPageProps) {
|
|
|
310
285
|
} else {
|
|
311
286
|
flash(buildRetryFailureMessage(call.result as RetryFailureBody | null, t), 'error')
|
|
312
287
|
}
|
|
313
|
-
}, [
|
|
288
|
+
}, [runId, router, runMutation, t])
|
|
314
289
|
|
|
315
290
|
if (isLoading) return <Page><PageBody><LoadingMessage label={t('data_sync.runs.detail.title')} /></PageBody></Page>
|
|
316
291
|
if (isNotFound) {
|
|
@@ -15,6 +15,7 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
15
15
|
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
16
16
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
17
17
|
import { assertEntityAclForRequest, getDeclaredCustomEntityRestriction } from '../lib/entityAcl'
|
|
18
|
+
import { loadCustomFieldKinds } from '../lib/scoped-field-defs'
|
|
18
19
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
19
20
|
|
|
20
21
|
const logger = createLogger('entities').child({ component: 'records' })
|
|
@@ -197,24 +198,34 @@ export async function GET(req: Request) {
|
|
|
197
198
|
if (organizationIds && organizationIds.length === 0) {
|
|
198
199
|
return NextResponse.json({ items: [], total: 0, page, pageSize, totalPages: 0 })
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
201
|
+
// Doc storage keeps custom-field values as JSON, so a checkbox can arrive as the
|
|
202
|
+
// string "true"/"1" and must be handed back to the form as a real boolean. That
|
|
203
|
+
// parse is only ever correct for fields DECLARED boolean: applied blind it also
|
|
204
|
+
// rewrites legitimate string values whose text happens to be a boolean token —
|
|
205
|
+
// a `select` option `no` came back as `false`, and the edit form then wrote that
|
|
206
|
+
// boolean back over the stored option (#5791). Consult the declared kind instead.
|
|
207
|
+
const needsFieldKinds = isCustomEntity || qpEntries.some(([key]) => key.startsWith('cf_'))
|
|
208
|
+
const fieldKinds = needsFieldKinds
|
|
209
|
+
? await loadCustomFieldKinds(em, { entityId, organizationId: scope.selectedId ?? auth.orgId ?? null, tenantId: auth.tenantId ?? null })
|
|
210
|
+
: new Map<string, string>()
|
|
211
|
+
const isBooleanField = (key: string) => fieldKinds.get(key) === 'boolean'
|
|
212
|
+
const parseBooleanField = (key: string, value: unknown) => {
|
|
213
|
+
if (typeof value !== 'string' || !isBooleanField(key)) return value
|
|
209
214
|
const parsed = parseBooleanToken(value)
|
|
210
215
|
return parsed === null ? value : parsed
|
|
211
216
|
}
|
|
217
|
+
const normalizeCustomEntityValue = (key: string, value: unknown) => {
|
|
218
|
+
if (Array.isArray(value)) return value.map((entry) => parseBooleanField(key, entry))
|
|
219
|
+
return parseBooleanField(key, value)
|
|
220
|
+
}
|
|
212
221
|
const mapRow = (row: any) => {
|
|
213
222
|
if (!isCustomEntity || !row || typeof row !== 'object') return row
|
|
214
223
|
const out: Record<string, unknown> = {}
|
|
215
224
|
for (const [k, v] of Object.entries(row)) {
|
|
216
|
-
if (k.startsWith('cf_'))
|
|
217
|
-
|
|
225
|
+
if (k.startsWith('cf_')) {
|
|
226
|
+
const bare = k.replace(/^cf_/, '')
|
|
227
|
+
out[bare] = normalizeCustomEntityValue(bare, v)
|
|
228
|
+
} else out[k] = v
|
|
218
229
|
}
|
|
219
230
|
return out
|
|
220
231
|
}
|
|
@@ -235,8 +246,7 @@ export async function GET(req: Request) {
|
|
|
235
246
|
const values = parseCommaSeparatedList(val)
|
|
236
247
|
;(filtersObj as any)[key] = { $in: values }
|
|
237
248
|
} else {
|
|
238
|
-
|
|
239
|
-
;(filtersObj as any)[key] = parsed === null ? val : parsed
|
|
249
|
+
;(filtersObj as any)[key] = parseBooleanField(key.replace(/^cf_/, ''), val)
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
} else if (allowAnyKey) {
|
|
@@ -244,8 +254,7 @@ export async function GET(req: Request) {
|
|
|
244
254
|
const values = parseCommaSeparatedList(val)
|
|
245
255
|
;(filtersObj as any)[key] = { $in: values }
|
|
246
256
|
} else {
|
|
247
|
-
|
|
248
|
-
;(filtersObj as any)[key] = parsed === null ? val : parsed
|
|
257
|
+
;(filtersObj as any)[key] = parseBooleanField(key, val)
|
|
249
258
|
}
|
|
250
259
|
} else {
|
|
251
260
|
if (['id', 'created_at', 'updated_at', 'deleted_at', 'name', 'title', 'email'].includes(key)) {
|