@open-mercato/core 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba
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/data_sync/lib/sync-schedule-service.js +25 -23
- package/dist/modules/data_sync/lib/sync-schedule-service.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/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/data_sync/lib/sync-schedule-service.ts +32 -26
- 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
|
@@ -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) {
|
|
@@ -40,11 +40,11 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
40
40
|
return schedulerService
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function buildScheduleName(row:
|
|
43
|
+
function buildScheduleName(row: { integrationId: string; entityType: string; direction: 'import' | 'export' }): string {
|
|
44
44
|
return `Data sync: ${row.integrationId} ${row.entityType} ${row.direction}`
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function buildScheduleDescription(row:
|
|
47
|
+
function buildScheduleDescription(row: { integrationId: string; entityType: string; direction: 'import' | 'export' }): string {
|
|
48
48
|
return `Scheduled ${row.direction} for ${row.integrationId} (${row.entityType})`
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -156,8 +156,36 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
156
156
|
})
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
const id = existing?.id ?? randomUUID()
|
|
160
|
+
const scheduledJobId = existing?.scheduledJobId ?? id
|
|
161
|
+
|
|
162
|
+
// Validate the schedule (and register it with the scheduler) before writing
|
|
163
|
+
// the SyncSchedule row — an unparseable scheduleValue must not leave a
|
|
164
|
+
// persisted row with no working schedule behind it.
|
|
165
|
+
await requireScheduler().register({
|
|
166
|
+
id: scheduledJobId,
|
|
167
|
+
name: buildScheduleName(input),
|
|
168
|
+
description: buildScheduleDescription(input),
|
|
169
|
+
scopeType: 'organization',
|
|
170
|
+
organizationId: scope.organizationId,
|
|
171
|
+
tenantId: scope.tenantId,
|
|
172
|
+
scheduleType: input.scheduleType,
|
|
173
|
+
scheduleValue: input.scheduleValue,
|
|
174
|
+
timezone: input.timezone,
|
|
175
|
+
targetType: 'queue',
|
|
176
|
+
targetQueue: 'data-sync-scheduled',
|
|
177
|
+
targetPayload: {
|
|
178
|
+
scheduleId: id,
|
|
179
|
+
scope,
|
|
180
|
+
},
|
|
181
|
+
requireFeature: 'data_sync.run',
|
|
182
|
+
sourceType: 'module',
|
|
183
|
+
sourceModule: 'data_sync',
|
|
184
|
+
isEnabled: input.isEnabled,
|
|
185
|
+
})
|
|
186
|
+
|
|
159
187
|
const row = existing ?? em.create(SyncSchedule, {
|
|
160
|
-
id
|
|
188
|
+
id,
|
|
161
189
|
integrationId: input.integrationId,
|
|
162
190
|
entityType: input.entityType,
|
|
163
191
|
direction: input.direction,
|
|
@@ -178,7 +206,7 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
178
206
|
row.timezone = input.timezone
|
|
179
207
|
row.fullSync = input.fullSync
|
|
180
208
|
row.isEnabled = input.isEnabled
|
|
181
|
-
row.scheduledJobId =
|
|
209
|
+
row.scheduledJobId = scheduledJobId
|
|
182
210
|
|
|
183
211
|
if (!existing) {
|
|
184
212
|
em.persist(row)
|
|
@@ -186,28 +214,6 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
186
214
|
|
|
187
215
|
await em.flush()
|
|
188
216
|
|
|
189
|
-
await requireScheduler().register({
|
|
190
|
-
id: row.scheduledJobId,
|
|
191
|
-
name: buildScheduleName(row),
|
|
192
|
-
description: buildScheduleDescription(row),
|
|
193
|
-
scopeType: 'organization',
|
|
194
|
-
organizationId: scope.organizationId,
|
|
195
|
-
tenantId: scope.tenantId,
|
|
196
|
-
scheduleType: row.scheduleType,
|
|
197
|
-
scheduleValue: row.scheduleValue,
|
|
198
|
-
timezone: row.timezone,
|
|
199
|
-
targetType: 'queue',
|
|
200
|
-
targetQueue: 'data-sync-scheduled',
|
|
201
|
-
targetPayload: {
|
|
202
|
-
scheduleId: row.id,
|
|
203
|
-
scope,
|
|
204
|
-
},
|
|
205
|
-
requireFeature: 'data_sync.run',
|
|
206
|
-
sourceType: 'module',
|
|
207
|
-
sourceModule: 'data_sync',
|
|
208
|
-
isEnabled: row.isEnabled,
|
|
209
|
-
})
|
|
210
|
-
|
|
211
217
|
return row
|
|
212
218
|
},
|
|
213
219
|
|
|
@@ -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)) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/core'
|
|
2
|
+
import { CustomFieldDef } from '../data/entities'
|
|
3
|
+
|
|
4
|
+
export type ScopedCustomFieldDefsOptions = {
|
|
5
|
+
entityId: string
|
|
6
|
+
organizationId?: string | null
|
|
7
|
+
tenantId?: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Definitions overlay by scope: an org+tenant row shadows a tenant-global row,
|
|
11
|
+
// which shadows an instance-global one. Duplicate keys within the same scope are
|
|
12
|
+
// resolved by recency so the newest definition wins.
|
|
13
|
+
const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
|
|
14
|
+
|
|
15
|
+
const definitionTime = (def: CustomFieldDef) =>
|
|
16
|
+
def.updatedAt instanceof Date ? def.updatedAt.getTime() : new Date(def.updatedAt).getTime()
|
|
17
|
+
|
|
18
|
+
export async function loadScopedCustomFieldDefs(
|
|
19
|
+
em: EntityManager,
|
|
20
|
+
opts: ScopedCustomFieldDefsOptions,
|
|
21
|
+
): Promise<Map<string, CustomFieldDef>> {
|
|
22
|
+
const organizationId = opts.organizationId ?? null
|
|
23
|
+
const tenantId = opts.tenantId ?? null
|
|
24
|
+
const defs = await em.find(CustomFieldDef, {
|
|
25
|
+
entityId: opts.entityId,
|
|
26
|
+
isActive: true,
|
|
27
|
+
deletedAt: null,
|
|
28
|
+
$and: [
|
|
29
|
+
{
|
|
30
|
+
$or: organizationId === null
|
|
31
|
+
? [{ organizationId: null }]
|
|
32
|
+
: [{ organizationId }, { organizationId: null }],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
$or: tenantId === null
|
|
36
|
+
? [{ tenantId: null }]
|
|
37
|
+
: [{ tenantId }, { tenantId: null }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
} as any)
|
|
41
|
+
|
|
42
|
+
const byKey = new Map<string, CustomFieldDef>()
|
|
43
|
+
for (const def of defs) {
|
|
44
|
+
const existing = byKey.get(def.key)
|
|
45
|
+
if (!existing) {
|
|
46
|
+
byKey.set(def.key, def)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const nextScore = scopeScore(def)
|
|
50
|
+
const existingScore = scopeScore(existing)
|
|
51
|
+
if (nextScore > existingScore) {
|
|
52
|
+
byKey.set(def.key, def)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (nextScore < existingScore) continue
|
|
56
|
+
if (definitionTime(def) >= definitionTime(existing)) byKey.set(def.key, def)
|
|
57
|
+
}
|
|
58
|
+
return byKey
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Kind lookup used by read paths that must not reinterpret a stored value against
|
|
62
|
+
// the wrong type. Failures degrade to an empty map: callers then leave values as
|
|
63
|
+
// stored, which is always safer than coercing them blind.
|
|
64
|
+
export async function loadCustomFieldKinds(
|
|
65
|
+
em: EntityManager,
|
|
66
|
+
opts: ScopedCustomFieldDefsOptions,
|
|
67
|
+
): Promise<Map<string, string>> {
|
|
68
|
+
try {
|
|
69
|
+
const defs = await loadScopedCustomFieldDefs(em, opts)
|
|
70
|
+
const kinds = new Map<string, string>()
|
|
71
|
+
for (const [key, def] of defs) kinds.set(key, String(def.kind || ''))
|
|
72
|
+
return kinds
|
|
73
|
+
} catch {
|
|
74
|
+
return new Map<string, string>()
|
|
75
|
+
}
|
|
76
|
+
}
|