@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
@@ -1,6 +1,6 @@
1
1
  import type { EntityManager } from '@mikro-orm/core'
2
- import { CustomFieldDef } from '../data/entities'
3
2
  import { validateValuesAgainstDefs } from '@open-mercato/shared/modules/entities/validation'
3
+ import { loadScopedCustomFieldDefs } from './scoped-field-defs'
4
4
 
5
5
  export async function validateCustomFieldValuesServer(
6
6
  em: EntityManager,
@@ -12,51 +12,11 @@ export async function validateCustomFieldValuesServer(
12
12
  rejectUndeclaredKeys?: boolean
13
13
  },
14
14
  ): Promise<{ ok: boolean; fieldErrors: Record<string, string> }> {
15
- const organizationId = opts.organizationId ?? null
16
- const tenantId = opts.tenantId ?? null
17
- const defs = await em.find(CustomFieldDef, {
15
+ const byKey = await loadScopedCustomFieldDefs(em, {
18
16
  entityId: opts.entityId,
19
- isActive: true,
20
- deletedAt: null,
21
- $and: [
22
- {
23
- $or: organizationId === null
24
- ? [{ organizationId: null }]
25
- : [{ organizationId }, { organizationId: null }],
26
- },
27
- {
28
- $or: tenantId === null
29
- ? [{ tenantId: null }]
30
- : [{ tenantId }, { tenantId: null }],
31
- },
32
- ],
33
- } as any)
34
-
35
- // Prefer the most specific scope and newest definition for duplicate keys.
36
- const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
37
- const byKey = new Map<string, CustomFieldDef>()
38
- for (const d of defs) {
39
- const existing = byKey.get(d.key)
40
- if (!existing) {
41
- byKey.set(d.key, d)
42
- continue
43
- }
44
- const nextScore = scopeScore(d)
45
- const existingScore = scopeScore(existing)
46
- if (nextScore > existingScore) {
47
- byKey.set(d.key, d)
48
- continue
49
- }
50
- if (nextScore < existingScore) continue
51
-
52
- const nextUpdatedAt = d.updatedAt instanceof Date ? d.updatedAt.getTime() : new Date(d.updatedAt).getTime()
53
- const existingUpdatedAt = existing.updatedAt instanceof Date
54
- ? existing.updatedAt.getTime()
55
- : new Date(existing.updatedAt).getTime()
56
- if (nextUpdatedAt >= existingUpdatedAt) {
57
- byKey.set(d.key, d)
58
- }
59
- }
17
+ organizationId: opts.organizationId,
18
+ tenantId: opts.tenantId,
19
+ })
60
20
  return validateValuesAgainstDefs(opts.values, Array.from(byKey.values()) as any, {
61
21
  rejectUndeclaredKeys: opts.rejectUndeclaredKeys === true,
62
22
  })
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
3
  import { extensionPoints } from '@open-mercato/core/modules/integrations/extension-points'
4
- import { usePathname, useRouter, useSearchParams } from 'next/navigation'
4
+ import { useRouter, useSearchParams } from 'next/navigation'
5
5
  import { z } from 'zod'
6
6
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
7
  import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
@@ -139,7 +139,7 @@ type LogEntry = {
139
139
 
140
140
  type IntegrationDetailPageProps = {
141
141
  params?: {
142
- id?: string | string[]
142
+ id?: string
143
143
  }
144
144
  }
145
145
 
@@ -289,18 +289,6 @@ const CATEGORY_ICONS: Record<string, React.ElementType> = {
289
289
  webhook: Webhook,
290
290
  }
291
291
 
292
- function resolveRouteId(value: string | string[] | undefined): string | undefined {
293
- if (Array.isArray(value)) return value[0]
294
- return value
295
- }
296
-
297
- function resolvePathnameId(pathname: string): string | undefined {
298
- const parts = pathname.split('/').filter(Boolean)
299
- const integrationId = parts.at(-1)
300
- if (!integrationId || integrationId === 'integrations' || integrationId === 'bundle') return undefined
301
- return decodeURIComponent(integrationId)
302
- }
303
-
304
292
  function buildCredentialFields(
305
293
  credFields: CredentialField[],
306
294
  secretFieldsConfigured: SecretFieldsConfigured,
@@ -439,10 +427,9 @@ function splitLogPayload(payload: Record<string, unknown> | null | undefined) {
439
427
  }
440
428
 
441
429
  export default function IntegrationDetailPage({ params }: IntegrationDetailPageProps) {
442
- const pathname = usePathname()
443
430
  const router = useRouter()
444
431
  const searchParams = useSearchParams()
445
- const integrationId = resolveRouteId(params?.id) ?? resolvePathnameId(pathname)
432
+ const integrationId = params?.id
446
433
  const t = useT()
447
434
 
448
435
  const [detail, setDetail] = React.useState<IntegrationDetail | null>(null)
@@ -474,18 +461,9 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
474
461
  canManageCredentials,
475
462
  } = useIntegrationCredentialsFeatureAccess()
476
463
 
477
- const resolveCurrentIntegrationId = React.useCallback(() => {
478
- return integrationId ?? (
479
- typeof window !== 'undefined'
480
- ? resolvePathnameId(window.location.pathname)
481
- : undefined
482
- )
483
- }, [integrationId])
484
-
485
464
  const loadDetail = React.useCallback(async (options?: { showLoading?: boolean }) => {
486
465
  const showLoading = options?.showLoading ?? true
487
- const currentIntegrationId = resolveCurrentIntegrationId()
488
- if (!currentIntegrationId) {
466
+ if (!integrationId) {
489
467
  if (showLoading) setIsLoading(false)
490
468
  if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
491
469
  return
@@ -495,7 +473,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
495
473
  if (showLoading) setIsLoading(true)
496
474
  try {
497
475
  const call = await apiCall<IntegrationDetail>(
498
- `/api/integrations/${encodeURIComponent(currentIntegrationId)}`,
476
+ `/api/integrations/${encodeURIComponent(integrationId)}`,
499
477
  undefined,
500
478
  { fallback: null },
501
479
  )
@@ -515,17 +493,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
515
493
  if (showLoading) setError(t('integrations.detail.loadError', 'Failed to load integration'))
516
494
  if (showLoading) setIsLoading(false)
517
495
  }
518
- }, [resolveCurrentIntegrationId, t])
496
+ }, [integrationId, t])
519
497
 
520
498
  const loadCredentials = React.useCallback(async () => {
521
- const currentIntegrationId = resolveCurrentIntegrationId()
522
- if (!currentIntegrationId) return
499
+ if (!integrationId) return
523
500
  const call = await apiCall<{
524
501
  credentials: Record<string, unknown>
525
502
  secretFieldsConfigured?: SecretFieldsConfigured
526
503
  updatedAt?: string | null
527
504
  }>(
528
- `/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`,
505
+ `/api/integrations/${encodeURIComponent(integrationId)}/credentials`,
529
506
  { headers: credentialsRequestHeaders },
530
507
  { fallback: null },
531
508
  )
@@ -535,7 +512,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
535
512
  }
536
513
  if (call.ok && call.result?.credentials) {
537
514
  const next = { ...call.result.credentials }
538
- if (currentIntegrationId === 'storage_s3') {
515
+ if (integrationId === 'storage_s3') {
539
516
  const authMode = next.authMode
540
517
  if (authMode !== 'access_keys' && authMode !== 'ambient') {
541
518
  const hasKeys = Boolean(next.accessKeyId || next.secretAccessKey)
@@ -545,13 +522,12 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
545
522
  setCredValues(next)
546
523
  setCredentialsFormKey((current) => current + 1)
547
524
  }
548
- }, [resolveCurrentIntegrationId])
525
+ }, [integrationId])
549
526
 
550
527
  const loadLogs = React.useCallback(async () => {
551
- const currentIntegrationId = resolveCurrentIntegrationId()
552
- if (!currentIntegrationId) return
528
+ if (!integrationId) return
553
529
  setIsLoadingLogs(true)
554
- const params = new URLSearchParams({ integrationId: currentIntegrationId, pageSize: '50' })
530
+ const params = new URLSearchParams({ integrationId, pageSize: '50' })
555
531
  if (logLevel) params.set('level', logLevel)
556
532
  const call = await apiCall<{ items: LogEntry[] }>(
557
533
  `/api/integrations/logs?${params.toString()}`,
@@ -562,7 +538,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
562
538
  setLogs(call.result.items)
563
539
  }
564
540
  setIsLoadingLogs(false)
565
- }, [logLevel, resolveCurrentIntegrationId])
541
+ }, [logLevel, integrationId])
566
542
 
567
543
  const detailWidgetSpotId = React.useMemo(
568
544
  () => resolveIntegrationDetailWidgetSpotId(detail?.integration ?? null, extensionPoints.hosts.legacyDetailTabs.spotId),
@@ -706,16 +682,15 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
706
682
  React.useEffect(() => { void loadLogs() }, [loadLogs])
707
683
 
708
684
  const handleToggleState = React.useCallback(async (enabled: boolean) => {
709
- const currentIntegrationId = resolveCurrentIntegrationId()
710
- if (!currentIntegrationId) return
685
+ if (!integrationId) return
711
686
  setIsTogglingState(true)
712
687
  try {
713
688
  const call = await runMutationWithContext({
714
689
  actionId: 'toggle-state',
715
- mutationPayload: { integrationId: currentIntegrationId, isEnabled: enabled },
690
+ mutationPayload: { integrationId, isEnabled: enabled },
716
691
  operation: () => withScopedApiRequestHeaders(
717
692
  buildOptimisticLockHeader(detail?.state.updatedAt),
718
- () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/state`, {
693
+ () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
719
694
  method: 'PUT',
720
695
  headers: { 'Content-Type': 'application/json' },
721
696
  body: JSON.stringify({ isEnabled: enabled }),
@@ -741,11 +716,10 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
741
716
  } finally {
742
717
  setIsTogglingState(false)
743
718
  }
744
- }, [detail?.state.updatedAt, refreshDetail, resolveCurrentIntegrationId, runMutationWithContext, t])
719
+ }, [detail?.state.updatedAt, refreshDetail, integrationId, runMutationWithContext, t])
745
720
 
746
721
  const handleSaveCredentials = React.useCallback(async (values: Record<string, unknown>) => {
747
- const currentIntegrationId = resolveCurrentIntegrationId()
748
- if (!currentIntegrationId) return
722
+ if (!integrationId) return
749
723
  setIsSavingCredentials(true)
750
724
  try {
751
725
  const credentialFields = (
@@ -754,7 +728,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
754
728
  ?? []
755
729
  )
756
730
  const savePayload = buildIntegrationCredentialSavePayload(
757
- currentIntegrationId,
731
+ integrationId,
758
732
  values,
759
733
  credentialFields,
760
734
  secretFieldsConfigured,
@@ -762,10 +736,10 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
762
736
  const call = await runMutationWithContext({
763
737
  actionId: 'save-credentials',
764
738
  tabId: 'credentials',
765
- mutationPayload: { integrationId: currentIntegrationId, ...savePayload },
739
+ mutationPayload: { integrationId, ...savePayload },
766
740
  operation: () => withScopedApiRequestHeaders(
767
741
  buildOptimisticLockHeader(credentialsUpdatedAt),
768
- () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`, {
742
+ () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/credentials`, {
769
743
  method: 'PUT',
770
744
  headers: { 'Content-Type': 'application/json' },
771
745
  body: JSON.stringify(savePayload),
@@ -791,19 +765,18 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
791
765
  } finally {
792
766
  setIsSavingCredentials(false)
793
767
  }
794
- }, [credentialsUpdatedAt, detail, loadCredentials, resolveCurrentIntegrationId, runMutationWithContext, secretFieldsConfigured, t])
768
+ }, [credentialsUpdatedAt, detail, loadCredentials, integrationId, runMutationWithContext, secretFieldsConfigured, t])
795
769
 
796
770
  const handleVersionChange = React.useCallback(async (version: string) => {
797
- const currentIntegrationId = resolveCurrentIntegrationId()
798
- if (!currentIntegrationId) return
771
+ if (!integrationId) return
799
772
  try {
800
773
  const call = await runMutationWithContext({
801
774
  actionId: 'change-version',
802
775
  tabId: 'version',
803
- mutationPayload: { integrationId: currentIntegrationId, apiVersion: version },
776
+ mutationPayload: { integrationId, apiVersion: version },
804
777
  operation: () => withScopedApiRequestHeaders(
805
778
  buildOptimisticLockHeader(detail?.state.updatedAt),
806
- () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/version`, {
779
+ () => apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/version`, {
807
780
  method: 'PUT',
808
781
  headers: { 'Content-Type': 'application/json' },
809
782
  body: JSON.stringify({ apiVersion: version }),
@@ -820,19 +793,18 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
820
793
  } catch {
821
794
  flash(t('integrations.detail.version.saveError'), 'error')
822
795
  }
823
- }, [detail?.state.updatedAt, refreshDetail, resolveCurrentIntegrationId, runMutationWithContext, t])
796
+ }, [detail?.state.updatedAt, refreshDetail, integrationId, runMutationWithContext, t])
824
797
 
825
798
  const handleHealthCheck = React.useCallback(async () => {
826
- const currentIntegrationId = resolveCurrentIntegrationId()
827
- if (!currentIntegrationId) return
799
+ if (!integrationId) return
828
800
  setIsCheckingHealth(true)
829
801
  try {
830
802
  const call = await runMutationWithContext({
831
803
  actionId: 'run-health-check',
832
804
  tabId: 'health',
833
- mutationPayload: { integrationId: currentIntegrationId },
805
+ mutationPayload: { integrationId },
834
806
  operation: () => apiCall<HealthCheckResponse>(
835
- `/api/integrations/${encodeURIComponent(currentIntegrationId)}/health`,
807
+ `/api/integrations/${encodeURIComponent(integrationId)}/health`,
836
808
  { method: 'POST' },
837
809
  { fallback: null },
838
810
  ),
@@ -859,7 +831,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
859
831
  } finally {
860
832
  setIsCheckingHealth(false)
861
833
  }
862
- }, [refreshLogs, resolveCurrentIntegrationId, runMutationWithContext, t])
834
+ }, [refreshLogs, integrationId, runMutationWithContext, t])
863
835
 
864
836
  const hasVersions = Boolean(detail?.integration.apiVersions?.length)
865
837
  const integration = detail?.integration ?? null
@@ -1052,17 +1024,16 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
1052
1024
  }, [searchParams, visibleTabIds])
1053
1025
 
1054
1026
  const handleTabChange = React.useCallback((nextValue: string) => {
1055
- const currentIntegrationId = resolveCurrentIntegrationId()
1056
1027
  const nextTab = resolveRequestedIntegrationDetailTab(nextValue, visibleTabIds)
1057
1028
  setActiveTab(nextTab)
1058
- if (!currentIntegrationId) return
1059
- const basePath = `/backend/integrations/${encodeURIComponent(currentIntegrationId)}`
1029
+ if (!integrationId) return
1030
+ const basePath = `/backend/integrations/${encodeURIComponent(integrationId)}`
1060
1031
  const params = new URLSearchParams(searchParams?.toString() ?? '')
1061
1032
  if (nextTab === 'credentials') params.delete('tab')
1062
1033
  else params.set('tab', nextTab)
1063
1034
  const query = params.toString()
1064
1035
  router.replace(query ? `${basePath}?${query}` : basePath)
1065
- }, [resolveCurrentIntegrationId, router, searchParams, visibleTabIds])
1036
+ }, [integrationId, router, searchParams, visibleTabIds])
1066
1037
 
1067
1038
  React.useEffect(() => {
1068
1039
  if (!runIdFromUrl) {
@@ -1,7 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
3
  import Link from 'next/link'
4
- import { usePathname } from 'next/navigation'
5
4
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
5
  import { Card, CardHeader, CardTitle, CardContent } from '@open-mercato/ui/primitives/card'
7
6
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -68,25 +67,12 @@ type BundleDetail = {
68
67
 
69
68
  type BundleConfigPageProps = {
70
69
  params?: {
71
- id?: string | string[]
70
+ id?: string
72
71
  }
73
72
  }
74
73
 
75
- function resolveRouteId(value: string | string[] | undefined): string | undefined {
76
- if (Array.isArray(value)) return value[0]
77
- return value
78
- }
79
-
80
- function resolvePathnameId(pathname: string): string | undefined {
81
- const parts = pathname.split('/').filter(Boolean)
82
- const bundleId = parts.at(-1)
83
- if (!bundleId || bundleId === 'bundle' || bundleId === 'integrations') return undefined
84
- return decodeURIComponent(bundleId)
85
- }
86
-
87
74
  export default function BundleConfigPage({ params }: BundleConfigPageProps) {
88
- const pathname = usePathname()
89
- const bundleId = resolveRouteId(params?.id) ?? resolvePathnameId(pathname)
75
+ const bundleId = params?.id
90
76
  const t = useT()
91
77
 
92
78
  const [detail, setDetail] = React.useState<BundleDetail | null>(null)
@@ -107,17 +93,8 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
107
93
  contextId: mutationContextId,
108
94
  })
109
95
 
110
- const resolveCurrentBundleId = React.useCallback(() => {
111
- return bundleId ?? (
112
- typeof window !== 'undefined'
113
- ? resolvePathnameId(window.location.pathname)
114
- : undefined
115
- )
116
- }, [bundleId])
117
-
118
96
  const load = React.useCallback(async () => {
119
- const currentBundleId = resolveCurrentBundleId()
120
- if (!currentBundleId) {
97
+ if (!bundleId) {
121
98
  setError(t('integrations.detail.loadError'))
122
99
  setIsLoading(false)
123
100
  return
@@ -126,7 +103,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
126
103
  setError(null)
127
104
  setIsNotFound(false)
128
105
  const call = await apiCall<BundleDetail>(
129
- `/api/integrations/${encodeURIComponent(currentBundleId)}`,
106
+ `/api/integrations/${encodeURIComponent(bundleId)}`,
130
107
  undefined,
131
108
  { fallback: null },
132
109
  )
@@ -146,7 +123,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
146
123
  secretFieldsConfigured?: SecretFieldsConfigured
147
124
  updatedAt?: string | null
148
125
  }>(
149
- `/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`,
126
+ `/api/integrations/${encodeURIComponent(bundleId)}/credentials`,
150
127
  undefined,
151
128
  { fallback: null },
152
129
  )
@@ -156,7 +133,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
156
133
  }
157
134
  if (credCall.ok && credCall.result?.credentials) {
158
135
  const next = { ...credCall.result.credentials }
159
- if (currentBundleId === 'storage_s3') {
136
+ if (bundleId === 'storage_s3') {
160
137
  const authMode = next.authMode
161
138
  if (authMode !== 'access_keys' && authMode !== 'ambient') {
162
139
  const hasKeys = Boolean(next.accessKeyId || next.secretAccessKey)
@@ -169,13 +146,12 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
169
146
  ))
170
147
  }
171
148
  setIsLoading(false)
172
- }, [resolveCurrentBundleId, t])
149
+ }, [bundleId, t])
173
150
 
174
151
  React.useEffect(() => { void load() }, [load])
175
152
 
176
153
  const handleSaveCredentials = React.useCallback(async () => {
177
- const currentBundleId = resolveCurrentBundleId()
178
- if (!currentBundleId) return
154
+ if (!bundleId) return
179
155
  setIsSavingCreds(true)
180
156
  try {
181
157
  const savePayload = buildCredentialSavePayload(
@@ -184,19 +160,19 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
184
160
  secretFieldsConfigured,
185
161
  )
186
162
  const call = await runMutation({
187
- mutationPayload: { bundleId: currentBundleId, ...savePayload },
163
+ mutationPayload: { bundleId, ...savePayload },
188
164
  context: {
189
165
  formId: mutationContextId,
190
166
  operation: 'update',
191
167
  actionId: 'save-credentials',
192
168
  resourceKind: 'integrations.bundle',
193
- resourceId: currentBundleId,
194
- bundleId: currentBundleId,
169
+ resourceId: bundleId,
170
+ bundleId,
195
171
  retryLastMutation,
196
172
  },
197
173
  operation: () => withScopedApiRequestHeaders(
198
174
  buildOptimisticLockHeader(credentialsUpdatedAt),
199
- () => apiCall(`/api/integrations/${encodeURIComponent(currentBundleId)}/credentials`, {
175
+ () => apiCall(`/api/integrations/${encodeURIComponent(bundleId)}/credentials`, {
200
176
  method: 'PUT',
201
177
  headers: { 'Content-Type': 'application/json' },
202
178
  body: JSON.stringify(savePayload),
@@ -214,7 +190,7 @@ export default function BundleConfigPage({ params }: BundleConfigPageProps) {
214
190
  } finally {
215
191
  setIsSavingCreds(false)
216
192
  }
217
- }, [resolveCurrentBundleId, runMutation, mutationContextId, retryLastMutation, credValues, credentialsUpdatedAt, detail?.bundle?.credentials?.fields, load, secretFieldsConfigured, t])
193
+ }, [bundleId, runMutation, mutationContextId, retryLastMutation, credValues, credentialsUpdatedAt, detail?.bundle?.credentials?.fields, load, secretFieldsConfigured, t])
218
194
 
219
195
  const handleToggle = React.useCallback(async (integrationId: string, enabled: boolean, updatedAt?: string | null) => {
220
196
  setTogglingIds((prev) => new Set(prev).add(integrationId))
@@ -19,8 +19,8 @@ export const metadata = {
19
19
  requireFeatures: ['notifications.manage_preferences'],
20
20
  pageTitle: 'Notification Preferences',
21
21
  pageTitleKey: 'notifications.preferences.pageTitle',
22
- pageGroup: 'Profile',
23
- pageGroupKey: 'notifications.preferences.profileGroup',
22
+ pageGroup: 'Account',
23
+ pageGroupKey: 'profile.sections.account',
24
24
  pageOrder: 30,
25
25
  icon: slidersIcon,
26
26
  pageContext: 'profile' as const,
@@ -93,7 +93,7 @@ Use stable, grep-friendly ids:
93
93
  `progressService` is safe to run across many app/worker instances. Do not reintroduce read-modify-write transitions:
94
94
 
95
95
  - **Every status transition is a status-guarded `nativeUpdate` (CAS).** An update that matches zero rows lost the race — it MUST NOT emit events or overwrite the row. Allowed transitions: start from `pending|failed` (an already-running start is an idempotent no-op; `failed` allows queue retries and recovery from a wrong stale sweep), complete from `pending|running|failed`, fail from `pending|running`, cancel from `pending|running|failed`.
96
- - **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it. Exception: when a write path finds the job `failed`, it attempts exactly one revival through the start CAS (a live producer writing progress proves a stale sweep false-positived) before giving up. That revive CAS is narrowed to rows whose `errorMessage` starts with `STALE_SWEEP_ERROR_PREFIX`, so a job that failed for a real reason keeps its message and stack instead of being resurrected by a racing buffered writer; `completed`/`cancelled` are never revived, and genuinely dead producers never write again, so real sweeps stick. Only `startJob` uses the unrestricted CAS, because a queue retry restarts the whole unit of work. A revive never resets `startedAt` — progress counts are absolute across deliveries, so the elapsed window `calculateEta` divides them by must be too.
96
+ - **Progress writes are guarded on `status IN ('pending','running')`** — once another process finishes/cancels a job, buffered updaters stop writing to it. Exception: when a write path finds the job `failed`, it attempts exactly one revival through the start CAS (a live producer writing progress proves a stale sweep false-positived) before giving up. That revive CAS is narrowed to rows whose `errorMessage` starts with `STALE_SWEEP_ERROR_PREFIX`, so a job that failed for a real reason keeps its message and stack instead of being resurrected by a racing buffered writer; `completed`/`cancelled` are never revived, and genuinely dead producers never write again, so real sweeps stick. Only `startJob` uses the unrestricted CAS, because a queue retry restarts the whole unit of work. A revive never resets `startedAt` — progress counts are absolute across deliveries, so the elapsed window `calculateEta` divides them by must be too. A **zero-row revive CAS is not automatically the end of the write**: when the cached status is `failed` and the CAS misses, `incrementProgress` re-reads the row once, and adopts it as the throttle entry's snapshot if it came back writable — the miss usually means another writer (typically `touchJobHeartbeat`'s forked revive) already flipped the row to `running`, and a relative increment dropped there is unrecoverable.
97
97
  - **Long single units of work MUST call `touchJobHeartbeat`** — when one batch/step can outlast `STALE_JOB_TIMEOUT_SECONDS` (60s), run a keepalive that calls the optional `touchJobHeartbeat(jobId, ctx)` while awaiting it (see `withHeartbeat` in `data_sync/lib/sync-engine.ts`). It writes only `heartbeat_at`/`updated_at` on a **forked** EntityManager, so it is safe while the producer's own EM is mid-transaction, and it self-heals a falsely-swept job via the start CAS. Always optional-chain it (`progressService.touchJobHeartbeat?.(...)`) — third-party implementations may not provide it. The "no timers for durable work" rule below bans replacing queue delivery with timers, not an in-process keepalive around a pending await.
98
98
  - **`incrementProgress` deltas persist as atomic SQL increments** (`processed_count + n`), and the service reloads the database winner before returning or emitting, so concurrent writers never lose or report stale counts.
99
99
  - **Update-path reads use `disableIdentityMap: true`** — `isCancellationRequested` and lifecycle reads must always see fresh cross-process state, never a stale managed entity.
@@ -363,10 +363,33 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
363
363
 
364
364
  async incrementProgress(jobId, delta, ctx) {
365
365
  const entry = await ensureThrottleEntry(jobId, ctx)
366
- const job = entry.job
366
+ let job = entry.job
367
367
  if (TERMINAL_STATUSES.includes(job.status)) {
368
- const revived = job.status === 'failed' && (await startJobViaCas(em, job, ctx, { staleSweptOnly: true }))
369
- if (!revived) {
368
+ let writable = false
369
+ if (job.status === 'failed') {
370
+ writable = (await startJobViaCas(em, job, ctx, { staleSweptOnly: true })) != null
371
+ if (!writable) {
372
+ // The cached snapshot's status lags the row whenever another writer — typically
373
+ // the forked heartbeat in touchJobHeartbeat — already revived this job: the start
374
+ // CAS then misses because the row is `running`, not `failed`. Increments are
375
+ // relative, so returning here would lose the caller's delta for good (unlike
376
+ // updateProgress, whose next absolute write self-corrects). Unlike
377
+ // persistAndMaybeBroadcast, the re-read happens after the CAS rather than before
378
+ // it: startJobViaCas filters on database state, so retrying it against the fresh
379
+ // row could not change whether it matches.
380
+ const fresh = await loadFreshJob(jobId, ctx)
381
+ if (fresh) {
382
+ job = fresh
383
+ writable = PROGRESS_WRITABLE_STATUSES.includes(fresh.status)
384
+ // Adopting the fresh row cannot desync entry.pendingDelta, which is provably 0
385
+ // here: this guard is only reachable on a cached terminal status, and the only
386
+ // writers that cache one — ensureThrottleEntry's initial load and
387
+ // persistAndMaybeBroadcast's post-persist assignment — both leave the buffer empty.
388
+ if (writable) entry.job = fresh
389
+ }
390
+ }
391
+ }
392
+ if (!writable) {
370
393
  forgetJobThrottle(jobId)
371
394
  return job
372
395
  }
@@ -24,6 +24,7 @@ import { Badge } from '@open-mercato/ui/primitives/badge'
24
24
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
25
25
  import { Input } from '@open-mercato/ui/primitives/input'
26
26
  import { EmailInput } from '@open-mercato/ui/primitives/email-input'
27
+ import { formatDisplayDate, formatDisplayDateTime, toDateInputValue, toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
27
28
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
28
29
  import { ArrowRightLeft, Building2, CreditCard, Mail, Pencil, Plus, Send, Store, Truck, UserRound, Wand2, X } from 'lucide-react'
29
30
  import { FormHeader, type ActionItem } from '@open-mercato/ui/backend/forms'
@@ -3864,17 +3865,16 @@ export default function SalesDocumentDetailPage({
3864
3865
  label: t('sales.documents.detail.expectedDeliveryAt.label', 'Expected delivery'),
3865
3866
  emptyLabel: t('sales.documents.detail.empty', 'Not set'),
3866
3867
  placeholder: t('sales.documents.detail.expectedDeliveryAt.placeholder', 'Add expected delivery date'),
3867
- value: record?.expectedDeliveryAt
3868
- ? new Date(record.expectedDeliveryAt).toISOString().slice(0, 10)
3869
- : null,
3868
+ // UTC, not local: this editor submits a bare `yyyy-MM-dd`, which `z.coerce.date()`
3869
+ // stores as UTC midnight. Reading it back locally names the previous day west of UTC.
3870
+ value: toUtcDateInputValue(record?.expectedDeliveryAt),
3870
3871
  onSave: handleUpdateExpectedDeliveryAt,
3871
3872
  inputType: 'date',
3872
3873
  renderDisplay: (params) => {
3873
3874
  const { value, emptyLabel } = params
3874
- if (value && value.length) {
3875
- return <span className="text-sm text-muted-foreground">{new Date(value).toLocaleDateString(locale)}</span>
3876
- }
3877
- return <span className="text-sm text-muted-foreground">{emptyLabel}</span>
3875
+ return (
3876
+ <span className="text-sm text-muted-foreground">{formatDisplayDate(value, locale) ?? emptyLabel}</span>
3877
+ )
3878
3878
  },
3879
3879
  })
3880
3880
  }
@@ -3915,10 +3915,10 @@ export default function SalesDocumentDetailPage({
3915
3915
  render: () => (
3916
3916
  <SectionCard title={t('sales.documents.detail.timestamps', 'Timestamps')} muted>
3917
3917
  <p className="text-sm text-muted-foreground">
3918
- {t('sales.documents.detail.created', 'Created')}: {record?.createdAt ?? '—'}
3918
+ {t('sales.documents.detail.created', 'Created')}: {formatDisplayDateTime(record?.createdAt, locale) ?? '—'}
3919
3919
  </p>
3920
3920
  <p className="text-sm text-muted-foreground">
3921
- {t('sales.documents.detail.updated', 'Updated')}: {record?.updatedAt ?? '—'}
3921
+ {t('sales.documents.detail.updated', 'Updated')}: {formatDisplayDateTime(record?.updatedAt, locale) ?? '—'}
3922
3922
  </p>
3923
3923
  </SectionCard>
3924
3924
  ),
@@ -4707,28 +4707,22 @@ export default function SalesDocumentDetailPage({
4707
4707
  <InlineTextEditor
4708
4708
  key="date"
4709
4709
  label={t('sales.documents.detail.date', 'Date')}
4710
- value={
4711
- record?.placedAt
4712
- ? new Date(record.placedAt).toISOString().slice(0, 10)
4713
- : record?.createdAt
4714
- ? new Date(record.createdAt).toISOString().slice(0, 10)
4715
- : null
4716
- }
4710
+ // Two readings on purpose. `placedAt` is settable from this date input, so it is
4711
+ // stored as UTC midnight and must be read back in UTC or the field flips a day west
4712
+ // of UTC. `createdAt` is only ever stamped from a clock — a real instant, whose local
4713
+ // day is the one to show.
4714
+ value={toUtcDateInputValue(record?.placedAt) ?? toDateInputValue(record?.createdAt)}
4717
4715
  emptyLabel={t('sales.documents.detail.empty', 'Not set')}
4718
4716
  onSave={handleUpdatePlacedAt}
4719
4717
  inputType="date"
4720
4718
  activateOnClick
4721
4719
  containerClassName="h-full"
4722
4720
  saveLabel={t('customers.people.detail.inline.saveShortcut')}
4723
- renderDisplay={({ value, emptyLabel }) =>
4724
- value && value.length ? (
4725
- <span className="text-sm text-muted-foreground">
4726
- {new Date(value).toLocaleDateString(locale)}
4727
- </span>
4728
- ) : (
4729
- <span className="text-sm text-muted-foreground">{emptyLabel}</span>
4730
- )
4731
- }
4721
+ renderDisplay={({ value, emptyLabel }) => (
4722
+ <span className="text-sm text-muted-foreground">
4723
+ {formatDisplayDate(value, locale) ?? emptyLabel}
4724
+ </span>
4725
+ )}
4732
4726
  />
4733
4727
  </div>
4734
4728