@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.
Files changed (116) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/auth/commands/users.js +14 -1
  4. package/dist/modules/auth/commands/users.js.map +2 -2
  5. package/dist/modules/auth/data/entities.js +2 -2
  6. package/dist/modules/auth/data/entities.js.map +2 -2
  7. package/dist/modules/auth/lib/backendChrome.js +26 -3
  8. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  9. package/dist/modules/auth/lib/profile-sections.js +1 -1
  10. package/dist/modules/auth/lib/profile-sections.js.map +2 -2
  11. package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
  12. package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
  13. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
  14. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
  15. package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
  16. package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
  17. package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
  18. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  19. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
  20. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
  21. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
  22. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
  23. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
  24. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  25. package/dist/modules/customer_accounts/data/entities.js +4 -1
  26. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  27. package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
  28. package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
  29. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
  30. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
  31. package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
  32. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  33. package/dist/modules/customer_accounts/setup.js +2 -6
  34. package/dist/modules/customer_accounts/setup.js.map +2 -2
  35. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
  36. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  37. package/dist/modules/customers/commands/people.js +16 -1
  38. package/dist/modules/customers/commands/people.js.map +2 -2
  39. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
  40. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  41. package/dist/modules/data_sync/lib/sync-schedule-service.js +25 -23
  42. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  43. package/dist/modules/entities/api/records.js +18 -15
  44. package/dist/modules/entities/api/records.js.map +3 -3
  45. package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
  46. package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
  47. package/dist/modules/entities/lib/validation.js +4 -35
  48. package/dist/modules/entities/lib/validation.js.map +2 -2
  49. package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
  50. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  51. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
  52. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  53. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
  54. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
  55. package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
  56. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  57. package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
  58. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  59. package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
  60. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  61. package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
  62. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  63. package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
  64. package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
  65. package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
  66. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  67. package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
  68. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  69. package/package.json +7 -7
  70. package/src/modules/auth/commands/users.ts +14 -1
  71. package/src/modules/auth/data/entities.ts +2 -2
  72. package/src/modules/auth/lib/backendChrome.tsx +46 -2
  73. package/src/modules/auth/lib/profile-sections.tsx +14 -3
  74. package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
  75. package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
  76. package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
  77. package/src/modules/customer_accounts/AGENTS.md +3 -1
  78. package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
  79. package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
  80. package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
  81. package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
  82. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
  83. package/src/modules/customer_accounts/data/entities.ts +5 -1
  84. package/src/modules/customer_accounts/i18n/de.json +4 -2
  85. package/src/modules/customer_accounts/i18n/en.json +4 -2
  86. package/src/modules/customer_accounts/i18n/es.json +4 -2
  87. package/src/modules/customer_accounts/i18n/ko.json +4 -2
  88. package/src/modules/customer_accounts/i18n/pl.json +4 -2
  89. package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
  90. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
  91. package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
  92. package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
  93. package/src/modules/customer_accounts/setup.ts +2 -6
  94. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
  95. package/src/modules/customers/commands/people.ts +15 -0
  96. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
  97. package/src/modules/data_sync/lib/sync-schedule-service.ts +32 -26
  98. package/src/modules/entities/api/records.ts +24 -15
  99. package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
  100. package/src/modules/entities/lib/validation.ts +5 -45
  101. package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
  102. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
  103. package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
  104. package/src/modules/progress/AGENTS.md +1 -1
  105. package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
  106. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
  107. package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
  108. package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
  109. package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
  110. package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
  111. package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
  112. package/src/modules/sales/i18n/de.json +2 -0
  113. package/src/modules/sales/i18n/en.json +2 -0
  114. package/src/modules/sales/i18n/es.json +2 -0
  115. package/src/modules/sales/i18n/ko.json +2 -0
  116. package/src/modules/sales/i18n/pl.json +2 -0
@@ -48,9 +48,26 @@ interface PersonData {
48
48
  } | null
49
49
  }
50
50
 
51
- function InviteForm({ personEntityId, onSuccess }: { personEntityId: string; onSuccess: () => void }) {
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
- let cancelled = false
67
- async function loadPerson() {
68
- try {
69
- const call = await apiCall<PersonData>(
70
- `/api/customers/people/${encodeURIComponent(personEntityId)}`,
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
- loadPerson()
93
- return () => { cancelled = true }
94
- }, [personEntityId])
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 result = await apiCall(
284
- `/api/customer_accounts/admin/users-invite?personEntityId=${encodeURIComponent(personEntityId)}&pageSize=1`,
301
+ const byPerson = await fetchPendingInvitation(
302
+ `personEntityId=${encodeURIComponent(personEntityId)}`,
285
303
  )
286
- if (!result.ok) return null
287
- const json = result.result as Record<string, unknown> | null
288
- const items = json?.items as PendingInvitationData[] | undefined
289
- return items?.[0] || null
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
- {isLoadingInvitation ? (
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 && !isLoadingInvitation && (
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 personEntityId={personEntityId} onSuccess={handleInviteSuccess} />
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 { usePathname, useRouter } from 'next/navigation'
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 | 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 = resolveRouteId(params?.id) ?? resolvePathnameId(pathname)
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
- const currentRunId = resolveCurrentRunId()
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(currentRunId)}`,
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, resolveCurrentRunId, t])
165
+ }, [loadParameterLabels, runId, t])
188
166
 
189
167
  const loadLogs = React.useCallback(async (page?: number) => {
190
- const currentRunId = resolveCurrentRunId()
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: currentRunId, pageSize: String(LOG_PAGE_SIZE), page: String(targetPage) })
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
- }, [resolveCurrentRunId])
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
- const currentRunId = resolveCurrentRunId()
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(currentRunId)}/cancel`, {
248
+ operation: () => apiCall(`/api/data_sync/runs/${encodeURIComponent(runId)}/cancel`, {
273
249
  method: 'POST',
274
250
  }, { fallback: null }),
275
- mutationPayload: { runId: currentRunId },
251
+ mutationPayload: { runId },
276
252
  context: {
277
253
  operation: 'update',
278
254
  actionId: 'cancel-sync-run',
279
- runId: currentRunId,
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
- }, [resolveCurrentRunId, runMutation, t, loadRun])
264
+ }, [runId, runMutation, t, loadRun])
289
265
 
290
266
  const handleRetry = React.useCallback(async () => {
291
- const currentRunId = resolveCurrentRunId()
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(currentRunId)}/retry`, {
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: currentRunId, fromBeginning: false },
275
+ mutationPayload: { runId, fromBeginning: false },
301
276
  context: {
302
277
  operation: 'create',
303
278
  actionId: 'retry-sync-run',
304
- runId: currentRunId,
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
- }, [resolveCurrentRunId, router, runMutation, t])
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: SyncSchedule): string {
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: SyncSchedule): string {
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: randomUUID(),
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 = row.scheduledJobId ?? row.id
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
- const normalizeCustomEntityValue = (value: unknown) => {
201
- if (Array.isArray(value)) {
202
- return value.map((entry) => {
203
- if (typeof entry !== 'string') return entry
204
- const parsed = parseBooleanToken(entry)
205
- return parsed === null ? entry : parsed
206
- })
207
- }
208
- if (typeof value !== 'string') return value
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_')) out[k.replace(/^cf_/, '')] = normalizeCustomEntityValue(v)
217
- else out[k] = v
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
- const parsed = parseBooleanToken(val)
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
- const parsed = parseBooleanToken(val)
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
+ }