@open-mercato/core 0.7.1-develop.7136.1.0f76137a1d → 0.7.1-develop.7148.1.3076e5ccf7

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 (68) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/backend/users/page.js +4 -4
  3. package/dist/modules/auth/backend/users/page.js.map +2 -2
  4. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +1 -0
  5. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  6. package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -0
  7. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  8. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +1 -0
  9. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  10. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +17 -4
  11. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +25 -12
  13. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  14. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  15. package/dist/modules/customers/components/linking/adapters/personAdapter.js +1 -1
  16. package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
  17. package/dist/modules/customers/migrations/Migration20260901120000_reindex_pipeline_stage_colors.js +16 -0
  18. package/dist/modules/customers/migrations/Migration20260901120000_reindex_pipeline_stage_colors.js.map +7 -0
  19. package/dist/modules/dictionaries/migrations/Migration20260901120000_reindex_dictionary_entries.js +16 -0
  20. package/dist/modules/dictionaries/migrations/Migration20260901120000_reindex_dictionary_entries.js.map +7 -0
  21. package/dist/modules/integrations/backend/integrations/[id]/page.js +16 -3
  22. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  23. package/dist/modules/integrations/backend/integrations/useIntegrationCredentialsFeatureAccess.js +42 -0
  24. package/dist/modules/integrations/backend/integrations/useIntegrationCredentialsFeatureAccess.js.map +7 -0
  25. package/dist/modules/messages/api/route.js +2 -1
  26. package/dist/modules/messages/api/route.js.map +2 -2
  27. package/dist/modules/sales/commands/documents.js +11 -1
  28. package/dist/modules/sales/commands/documents.js.map +2 -2
  29. package/dist/modules/sales/lib/calculations.js +22 -0
  30. package/dist/modules/sales/lib/calculations.js.map +2 -2
  31. package/dist/modules/sales/lib/lineSnapshots.js +11 -1
  32. package/dist/modules/sales/lib/lineSnapshots.js.map +2 -2
  33. package/dist/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.js +16 -0
  34. package/dist/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.js.map +7 -0
  35. package/package.json +7 -7
  36. package/src/modules/auth/backend/users/page.tsx +4 -4
  37. package/src/modules/auth/i18n/de.json +4 -0
  38. package/src/modules/auth/i18n/en.json +4 -0
  39. package/src/modules/auth/i18n/es.json +4 -0
  40. package/src/modules/auth/i18n/ko.json +4 -0
  41. package/src/modules/auth/i18n/pl.json +4 -0
  42. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +1 -0
  43. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -0
  44. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +1 -0
  45. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +17 -4
  46. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +40 -14
  47. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +2 -0
  48. package/src/modules/customers/components/linking/adapters/personAdapter.tsx +2 -1
  49. package/src/modules/customers/i18n/de.json +6 -1
  50. package/src/modules/customers/i18n/en.json +6 -1
  51. package/src/modules/customers/i18n/es.json +6 -1
  52. package/src/modules/customers/i18n/ko.json +6 -1
  53. package/src/modules/customers/i18n/pl.json +6 -1
  54. package/src/modules/customers/migrations/Migration20260901120000_reindex_pipeline_stage_colors.ts +22 -0
  55. package/src/modules/dictionaries/migrations/Migration20260901120000_reindex_dictionary_entries.ts +20 -0
  56. package/src/modules/integrations/backend/integrations/[id]/page.tsx +23 -2
  57. package/src/modules/integrations/backend/integrations/useIntegrationCredentialsFeatureAccess.ts +50 -0
  58. package/src/modules/integrations/i18n/de.json +1 -0
  59. package/src/modules/integrations/i18n/en.json +1 -0
  60. package/src/modules/integrations/i18n/es.json +1 -0
  61. package/src/modules/integrations/i18n/ko.json +1 -0
  62. package/src/modules/integrations/i18n/pl.json +1 -0
  63. package/src/modules/messages/api/route.ts +2 -1
  64. package/src/modules/sales/commands/documents.ts +14 -0
  65. package/src/modules/sales/lib/calculations.ts +41 -0
  66. package/src/modules/sales/lib/lineSnapshots.ts +23 -0
  67. package/src/modules/sales/lib/types.ts +9 -0
  68. package/src/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.ts +21 -0
@@ -1715,6 +1715,11 @@
1715
1715
  "customers.linking.person.dialogSubtitle": "Einen bestehenden Kontakt mit diesem Unternehmen verknüpfen",
1716
1716
  "customers.linking.person.dialogSubtitleFor": "Einen bestehenden Kontakt mit {{name}} verknüpfen",
1717
1717
  "customers.linking.person.dialogTitle": "Personen verknüpfen",
1718
+ "customers.linking.person.role.all": "Alle",
1719
+ "customers.linking.person.role.budgetHolder": "Budgetverantwortlicher",
1720
+ "customers.linking.person.role.contact": "Kontaktperson",
1721
+ "customers.linking.person.role.decisionMaker": "Entscheider",
1722
+ "customers.linking.person.role.stakeholder": "Stakeholder",
1718
1723
  "customers.linking.person.searchEmpty": "Keine passenden Personen gefunden.",
1719
1724
  "customers.linking.person.searchPlaceholder": "Alle Personen durchsuchen…",
1720
1725
  "customers.linking.person.sectionLabel": "PASSENDE KONTAKTE",
@@ -2658,7 +2663,7 @@
2658
2663
  "customers.schedule.task.priority.high": "Hoch",
2659
2664
  "customers.schedule.task.priority.low": "Niedrig",
2660
2665
  "customers.schedule.task.priority.medium": "Mittel",
2661
- "customers.schedule.task.priority.urgent": "Dringend",
2666
+ "customers.schedule.task.priority.none": "Keine",
2662
2667
  "customers.schedule.task.priorityLabel": "Priorität",
2663
2668
  "customers.schedule.task.save": "Aufgabe speichern",
2664
2669
  "customers.schedule.task.subtitle": "Erfassen Sie etwas zum Nachverfolgen",
@@ -1715,6 +1715,11 @@
1715
1715
  "customers.linking.person.dialogSubtitle": "Link an existing contact to this company",
1716
1716
  "customers.linking.person.dialogSubtitleFor": "Link an existing contact to {{name}}",
1717
1717
  "customers.linking.person.dialogTitle": "Link people",
1718
+ "customers.linking.person.role.all": "All",
1719
+ "customers.linking.person.role.budgetHolder": "Budget holder",
1720
+ "customers.linking.person.role.contact": "Contact",
1721
+ "customers.linking.person.role.decisionMaker": "Decision maker",
1722
+ "customers.linking.person.role.stakeholder": "Stakeholder",
1718
1723
  "customers.linking.person.searchEmpty": "No matching people found.",
1719
1724
  "customers.linking.person.searchPlaceholder": "Search all people…",
1720
1725
  "customers.linking.person.sectionLabel": "MATCHING CONTACTS",
@@ -2658,7 +2663,7 @@
2658
2663
  "customers.schedule.task.priority.high": "High",
2659
2664
  "customers.schedule.task.priority.low": "Low",
2660
2665
  "customers.schedule.task.priority.medium": "Medium",
2661
- "customers.schedule.task.priority.urgent": "Urgent",
2666
+ "customers.schedule.task.priority.none": "None",
2662
2667
  "customers.schedule.task.priorityLabel": "Priority",
2663
2668
  "customers.schedule.task.save": "Save task",
2664
2669
  "customers.schedule.task.subtitle": "Capture something to follow up on",
@@ -1715,6 +1715,11 @@
1715
1715
  "customers.linking.person.dialogSubtitle": "Vincula un contacto existente a esta empresa",
1716
1716
  "customers.linking.person.dialogSubtitleFor": "Vincula un contacto existente a {{name}}",
1717
1717
  "customers.linking.person.dialogTitle": "Vincular personas",
1718
+ "customers.linking.person.role.all": "Todos",
1719
+ "customers.linking.person.role.budgetHolder": "Responsable del presupuesto",
1720
+ "customers.linking.person.role.contact": "Contacto",
1721
+ "customers.linking.person.role.decisionMaker": "Responsable de la decisión",
1722
+ "customers.linking.person.role.stakeholder": "Parte interesada",
1718
1723
  "customers.linking.person.searchEmpty": "No se encontraron personas.",
1719
1724
  "customers.linking.person.searchPlaceholder": "Buscar todas las personas…",
1720
1725
  "customers.linking.person.sectionLabel": "CONTACTOS COINCIDENTES",
@@ -2658,7 +2663,7 @@
2658
2663
  "customers.schedule.task.priority.high": "Alta",
2659
2664
  "customers.schedule.task.priority.low": "Baja",
2660
2665
  "customers.schedule.task.priority.medium": "Media",
2661
- "customers.schedule.task.priority.urgent": "Urgente",
2666
+ "customers.schedule.task.priority.none": "Ninguna",
2662
2667
  "customers.schedule.task.priorityLabel": "Prioridad",
2663
2668
  "customers.schedule.task.save": "Guardar tarea",
2664
2669
  "customers.schedule.task.subtitle": "Captura algo para hacer seguimiento",
@@ -1715,6 +1715,11 @@
1715
1715
  "customers.linking.person.dialogSubtitle": "기존 연락처를 이 회사에 연결",
1716
1716
  "customers.linking.person.dialogSubtitleFor": "기존 연락처를 {{name}}에 연결",
1717
1717
  "customers.linking.person.dialogTitle": "사람 연결",
1718
+ "customers.linking.person.role.all": "전체",
1719
+ "customers.linking.person.role.budgetHolder": "예산 담당자",
1720
+ "customers.linking.person.role.contact": "연락 담당자",
1721
+ "customers.linking.person.role.decisionMaker": "의사결정권자",
1722
+ "customers.linking.person.role.stakeholder": "이해관계자",
1718
1723
  "customers.linking.person.searchEmpty": "일치하는 사람을 찾을 수 없습니다.",
1719
1724
  "customers.linking.person.searchPlaceholder": "전체 사람 검색…",
1720
1725
  "customers.linking.person.sectionLabel": "일치하는 연락처",
@@ -2658,7 +2663,7 @@
2658
2663
  "customers.schedule.task.priority.high": "높음",
2659
2664
  "customers.schedule.task.priority.low": "낮음",
2660
2665
  "customers.schedule.task.priority.medium": "보통",
2661
- "customers.schedule.task.priority.urgent": "긴급",
2666
+ "customers.schedule.task.priority.none": "없음",
2662
2667
  "customers.schedule.task.priorityLabel": "우선순위",
2663
2668
  "customers.schedule.task.save": "작업 저장",
2664
2669
  "customers.schedule.task.subtitle": "후속 조치가 필요한 사항을 기록하세요",
@@ -1715,6 +1715,11 @@
1715
1715
  "customers.linking.person.dialogSubtitle": "Powiąż istniejący kontakt z tą firmą",
1716
1716
  "customers.linking.person.dialogSubtitleFor": "Powiąż istniejący kontakt z {{name}}",
1717
1717
  "customers.linking.person.dialogTitle": "Powiąż osoby",
1718
+ "customers.linking.person.role.all": "Wszystkie",
1719
+ "customers.linking.person.role.budgetHolder": "Dysponent budżetu",
1720
+ "customers.linking.person.role.contact": "Kontakt",
1721
+ "customers.linking.person.role.decisionMaker": "Decydent",
1722
+ "customers.linking.person.role.stakeholder": "Interesariusz",
1718
1723
  "customers.linking.person.searchEmpty": "Nie znaleziono pasujących osób.",
1719
1724
  "customers.linking.person.searchPlaceholder": "Szukaj wszystkich osób…",
1720
1725
  "customers.linking.person.sectionLabel": "PASUJĄCE KONTAKTY",
@@ -2658,7 +2663,7 @@
2658
2663
  "customers.schedule.task.priority.high": "Wysoki",
2659
2664
  "customers.schedule.task.priority.low": "Niski",
2660
2665
  "customers.schedule.task.priority.medium": "Średni",
2661
- "customers.schedule.task.priority.urgent": "Pilny",
2666
+ "customers.schedule.task.priority.none": "Brak",
2662
2667
  "customers.schedule.task.priorityLabel": "Priorytet",
2663
2668
  "customers.schedule.task.save": "Zapisz zadanie",
2664
2669
  "customers.schedule.task.subtitle": "Zapisz coś do późniejszego wykonania",
@@ -0,0 +1,22 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ import { declareQueryIndexReindex } from '@open-mercato/shared/lib/query/migration-reindex';
3
+
4
+ /**
5
+ * Catch-up reindex for `Migration20260519120000_pipeline_stage_color_tones`, which rewrote
6
+ * `customer_dictionary_entries.color` in raw SQL and never notified the query index. On every
7
+ * install that already applied it, `entity_indexes.doc` for `customers:customer_dictionary_entry`
8
+ * still holds the legacy hex chip (`#facc15`, `#a855f7`, …) instead of the semantic tone, and so
9
+ * do the `search_tokens` rows derived from that document. Both migrations are forward-only, so a
10
+ * follow-up migration is the only route to repair existing installs.
11
+ *
12
+ * This migration executes no SQL — the declaration below is its entire payload.
13
+ */
14
+ export const queryIndexReindexEntityTypes = declareQueryIndexReindex([
15
+ 'customers:customer_dictionary_entry',
16
+ ]);
17
+
18
+ export class Migration20260901120000_reindex_pipeline_stage_colors extends Migration {
19
+ override up(): void | Promise<void> {}
20
+
21
+ override down(): void | Promise<void> {}
22
+ }
@@ -0,0 +1,20 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ import { declareQueryIndexReindex } from '@open-mercato/shared/lib/query/migration-reindex';
3
+
4
+ /**
5
+ * Catch-up reindex for `Migration20260410171544`, which added `position` and `is_default` to
6
+ * `dictionary_entries` and backfilled them in raw SQL. Documents written before that migration
7
+ * were never rebuilt, so `entity_indexes.doc` for `dictionaries:dictionary_entry` is missing both
8
+ * keys entirely rather than holding a stale value — the same absent emit, a different symptom.
9
+ *
10
+ * This migration executes no SQL — the declaration below is its entire payload.
11
+ */
12
+ export const queryIndexReindexEntityTypes = declareQueryIndexReindex([
13
+ 'dictionaries:dictionary_entry',
14
+ ]);
15
+
16
+ export class Migration20260901120000_reindex_dictionary_entries extends Migration {
17
+ override up(): void | Promise<void> {}
18
+
19
+ override down(): void | Promise<void> {}
20
+ }
@@ -58,6 +58,7 @@ import {
58
58
  type SecretFieldsConfigured,
59
59
  } from '../credential-secret-fields'
60
60
  import { isValidCredentialUrl } from '../../../lib/credentials-field-validation'
61
+ import { useIntegrationCredentialsFeatureAccess } from '../useIntegrationCredentialsFeatureAccess'
61
62
 
62
63
  type CredentialField = IntegrationCredentialField
63
64
  type BuiltInIntegrationDetailTab = 'credentials' | 'version' | 'health' | 'logs' | 'data-sync-schedule'
@@ -65,6 +66,11 @@ type IntegrationDetailTab = BuiltInIntegrationDetailTab | string
65
66
 
66
67
  const UNSUPPORTED_CREDENTIAL_FIELD_TYPES = new Set<CredentialFieldType>(['oauth', 'ssh_keypair'])
67
68
 
69
+ // `/api/integrations/{id}/credentials` requires `integrations.credentials.manage`, so a viewer
70
+ // without the grant gets an expected 403 that the permission notice already explains. Opting out
71
+ // of the global forbidden handling keeps it from raising an "Access denied" flash and throwing.
72
+ const credentialsRequestHeaders = { 'x-om-forbidden-redirect': '0' } as const
73
+
68
74
  function isEditableCredentialField(field: CredentialField): boolean {
69
75
  return !UNSUPPORTED_CREDENTIAL_FIELD_TYPES.has(field.type)
70
76
  }
@@ -463,6 +469,10 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
463
469
  const [activeTab, setActiveTab] = React.useState<IntegrationDetailTab>('credentials')
464
470
 
465
471
  const credentialsFormId = React.useId()
472
+ const {
473
+ isLoading: isLoadingCredentialsAccess,
474
+ canManageCredentials,
475
+ } = useIntegrationCredentialsFeatureAccess()
466
476
 
467
477
  const resolveCurrentIntegrationId = React.useCallback(() => {
468
478
  return integrationId ?? (
@@ -516,7 +526,7 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
516
526
  updatedAt?: string | null
517
527
  }>(
518
528
  `/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`,
519
- undefined,
529
+ { headers: credentialsRequestHeaders },
520
530
  { fallback: null },
521
531
  )
522
532
  if (call.ok && call.result) {
@@ -1032,7 +1042,10 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
1032
1042
  ? 'border-status-success-border bg-status-success-bg text-status-success-text'
1033
1043
  : 'border-status-neutral-border bg-status-neutral-bg text-status-neutral-text'
1034
1044
 
1035
- const showCredentialActions = showCredentialsTab && activeTab === 'credentials' && credentialFormFields.length > 0
1045
+ const showCredentialActions = showCredentialsTab
1046
+ && activeTab === 'credentials'
1047
+ && credentialFormFields.length > 0
1048
+ && canManageCredentials
1036
1049
 
1037
1050
  React.useEffect(() => {
1038
1051
  setActiveTab(resolveRequestedIntegrationDetailTab(searchParams?.get('tab'), visibleTabIds))
@@ -1280,6 +1293,14 @@ export default function IntegrationDetailPage({ params }: IntegrationDetailPageP
1280
1293
  <p className="text-sm text-muted-foreground">
1281
1294
  {t('integrations.detail.credentials.notConfigured')}
1282
1295
  </p>
1296
+ ) : isLoadingCredentialsAccess ? (
1297
+ <div className="flex justify-center py-8"><Spinner /></div>
1298
+ ) : !canManageCredentials ? (
1299
+ <EmptyState
1300
+ size="sm"
1301
+ icon={<Key className="h-8 w-8" aria-hidden="true" />}
1302
+ title={t('integrations.detail.credentials.noPermission', 'You do not have permission to manage credentials for this integration.')}
1303
+ />
1283
1304
  ) : (
1284
1305
  <CrudForm<Record<string, unknown>>
1285
1306
  key={`${resolvedIntegration.id}:${credentialsFormKey}`}
@@ -0,0 +1,50 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { hasFeature } from '@open-mercato/shared/security/features'
6
+
7
+ type FeatureCheckResponse = {
8
+ granted?: string[]
9
+ }
10
+
11
+ export function useIntegrationCredentialsFeatureAccess() {
12
+ const [granted, setGranted] = React.useState<string[]>([])
13
+ const [isLoading, setIsLoading] = React.useState(true)
14
+
15
+ React.useEffect(() => {
16
+ let cancelled = false
17
+
18
+ async function load() {
19
+ try {
20
+ const call = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
21
+ method: 'POST',
22
+ headers: { 'content-type': 'application/json' },
23
+ body: JSON.stringify({
24
+ features: ['integrations.credentials.manage'],
25
+ }),
26
+ })
27
+
28
+ if (!cancelled) {
29
+ setGranted(Array.isArray(call.result?.granted) ? call.result.granted : [])
30
+ }
31
+ } catch {
32
+ if (!cancelled) setGranted([])
33
+ } finally {
34
+ if (!cancelled) setIsLoading(false)
35
+ }
36
+ }
37
+
38
+ void load()
39
+
40
+ return () => {
41
+ cancelled = true
42
+ }
43
+ }, [])
44
+
45
+ return {
46
+ isLoading,
47
+ granted,
48
+ canManageCredentials: hasFeature(granted, 'integrations.credentials.manage'),
49
+ }
50
+ }
@@ -11,6 +11,7 @@
11
11
  "integrations.detail.back": "Zurück zu Integrationen",
12
12
  "integrations.detail.backToList": "Zurück zu Integrationen",
13
13
  "integrations.detail.credentials.bundleShared": "Gemeinsame Zugangsdaten aus Paket: {bundle}",
14
+ "integrations.detail.credentials.noPermission": "Sie haben keine Berechtigung, die Zugangsdaten für diese Integration zu verwalten.",
14
15
  "integrations.detail.credentials.notConfigured": "Noch keine Zugangsdaten konfiguriert",
15
16
  "integrations.detail.credentials.save": "Zugangsdaten speichern",
16
17
  "integrations.detail.credentials.saveError": "Zugangsdaten konnten nicht gespeichert werden",
@@ -11,6 +11,7 @@
11
11
  "integrations.detail.back": "Back to Integrations",
12
12
  "integrations.detail.backToList": "Back to integrations",
13
13
  "integrations.detail.credentials.bundleShared": "Shared credentials from bundle: {bundle}",
14
+ "integrations.detail.credentials.noPermission": "You do not have permission to manage credentials for this integration.",
14
15
  "integrations.detail.credentials.notConfigured": "No credentials configured yet",
15
16
  "integrations.detail.credentials.save": "Save Credentials",
16
17
  "integrations.detail.credentials.saveError": "Failed to save credentials",
@@ -11,6 +11,7 @@
11
11
  "integrations.detail.back": "Volver a integraciones",
12
12
  "integrations.detail.backToList": "Volver a integraciones",
13
13
  "integrations.detail.credentials.bundleShared": "Credenciales compartidas del paquete: {bundle}",
14
+ "integrations.detail.credentials.noPermission": "No tiene permiso para gestionar las credenciales de esta integración.",
14
15
  "integrations.detail.credentials.notConfigured": "No hay credenciales configuradas",
15
16
  "integrations.detail.credentials.save": "Guardar credenciales",
16
17
  "integrations.detail.credentials.saveError": "No se pudieron guardar las credenciales",
@@ -11,6 +11,7 @@
11
11
  "integrations.detail.back": "통합으로 돌아가기",
12
12
  "integrations.detail.backToList": "통합 목록으로 돌아가기",
13
13
  "integrations.detail.credentials.bundleShared": "번들에서 공유된 자격 증명: {bundle}",
14
+ "integrations.detail.credentials.noPermission": "이 통합의 자격 증명을 관리할 권한이 없습니다.",
14
15
  "integrations.detail.credentials.notConfigured": "아직 구성된 자격 증명이 없습니다",
15
16
  "integrations.detail.credentials.save": "자격 증명 저장",
16
17
  "integrations.detail.credentials.saveError": "자격 증명 저장에 실패했습니다",
@@ -11,6 +11,7 @@
11
11
  "integrations.detail.back": "Powrót do integracji",
12
12
  "integrations.detail.backToList": "Wróć do integracji",
13
13
  "integrations.detail.credentials.bundleShared": "Współdzielone dane z pakietu: {bundle}",
14
+ "integrations.detail.credentials.noPermission": "Nie masz uprawnień do zarządzania danymi uwierzytelniającymi tej integracji.",
14
15
  "integrations.detail.credentials.notConfigured": "Brak skonfigurowanych danych",
15
16
  "integrations.detail.credentials.save": "Zapisz dane",
16
17
  "integrations.detail.credentials.saveError": "Nie udało się zapisać danych",
@@ -22,6 +22,7 @@ import {
22
22
  composeSourceHintSchema,
23
23
  resolveComposeSourceChannelType,
24
24
  } from '../lib/composeSourceChannelType'
25
+ import { resolveMessageActionData } from '../lib/actions'
25
26
  import { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'
26
27
  import { getMessageType } from '../lib/message-types-registry'
27
28
  import { validateMessageObjectsForType } from '../lib/object-validation'
@@ -382,7 +383,7 @@ export async function GET(req: Request) {
382
383
  if (!message) return null
383
384
  const body = typeof message.body === 'string' ? message.body : ''
384
385
  const bodyPreview = body.substring(0, 150) + (body.length > 150 ? '...' : '')
385
- const actionData = message.actionData ?? null
386
+ const actionData = resolveMessageActionData(message)
386
387
  return {
387
388
  ...(senderMetaById.get(row.sender_user_id)
388
389
  ? {
@@ -134,6 +134,7 @@ import {
134
134
  mapOrderLineEntityToSnapshot,
135
135
  mapQuoteLineEntityToSnapshot,
136
136
  resolveUpsertDiscountFields,
137
+ resolveUpsertTotalsOrigin,
137
138
  } from "../lib/lineSnapshots";
138
139
  import { loadShippedQuantityByLine } from "../lib/shipments/snapshots";
139
140
  import { resolveDictionaryEntryValue, resolveCachedDictionaryEntryValue } from "../lib/dictionaries";
@@ -3038,6 +3039,13 @@ function isStoredRowSourcedLine(line: DocumentLineCreateInput): boolean {
3038
3039
  );
3039
3040
  }
3040
3041
 
3042
+ function isStoredRowSourcedTotalsLine(line: DocumentLineCreateInput): boolean {
3043
+ return (
3044
+ (line as Pick<SalesLineSnapshot, "totalsFromStoredRow">)
3045
+ .totalsFromStoredRow === true
3046
+ );
3047
+ }
3048
+
3041
3049
  function createLineSnapshotFromInput(
3042
3050
  line: DocumentLineCreateInput,
3043
3051
  lineNumber: number,
@@ -3079,6 +3087,10 @@ function createLineSnapshotFromInput(
3079
3087
  ...(isStoredRowSourcedLine(line)
3080
3088
  ? { discountAmountFromStoredRow: true }
3081
3089
  : { discountAmountBasis: line.discountAmountBasis ?? "unit" }),
3090
+ // Carried independently of the discount origin above: a line upsert can
3091
+ // take its discount from the caller while its totals still come off the
3092
+ // stored row, and only the caller half is worth reconciling (#5644).
3093
+ ...(isStoredRowSourcedTotalsLine(line) ? { totalsFromStoredRow: true } : {}),
3082
3094
  discountPercent: line.discountPercent ?? null,
3083
3095
  taxRate: line.taxRate ?? null,
3084
3096
  taxAmount: line.taxAmount ?? null,
@@ -7240,6 +7252,7 @@ const orderLineUpsertCommand: CommandHandler<
7240
7252
  parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
7241
7253
  totalGrossAmount:
7242
7254
  parsed.totalGrossAmount ?? existingSnapshot?.totalGrossAmount ?? null,
7255
+ ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
7243
7256
  configuration:
7244
7257
  parsed.configuration ?? existingSnapshot?.configuration ?? null,
7245
7258
  promotionCode:
@@ -7737,6 +7750,7 @@ const quoteLineUpsertCommand: CommandHandler<
7737
7750
  parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
7738
7751
  totalGrossAmount:
7739
7752
  parsed.totalGrossAmount ?? existingSnapshot?.totalGrossAmount ?? null,
7753
+ ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
7740
7754
  configuration:
7741
7755
  parsed.configuration ?? existingSnapshot?.configuration ?? null,
7742
7756
  promotionCode:
@@ -1,3 +1,4 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
1
2
  import {
2
3
  type SalesAdjustmentDraft,
3
4
  type SalesCalculationContext,
@@ -11,6 +12,8 @@ import {
11
12
  type SalesTotalsCalculationHook,
12
13
  } from './types'
13
14
 
15
+ const logger = createLogger('sales')
16
+
14
17
  function toNumber(value: unknown, fallback = 0): number {
15
18
  if (typeof value === 'number' && Number.isFinite(value)) return value
16
19
  if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
@@ -23,6 +26,12 @@ function round(value: number): number {
23
26
  return Math.round((value + Number.EPSILON) * 1e4) / 1e4
24
27
  }
25
28
 
29
+ // The engine rounds to the 4 decimals the numeric columns carry, but callers
30
+ // work in money at 2, so an exact comparison would report half a cent of
31
+ // honest rounding as a mismatch. Half a minor unit is the widest divergence
32
+ // that cannot be a real discrepancy and the narrowest that silences that noise.
33
+ const NET_RECONCILIATION_TOLERANCE = 0.005
34
+
26
35
  function extractAdjustmentTaxRate(adjustment: SalesAdjustmentDraft): number | null {
27
36
  const metadata = (adjustment.metadata ?? {}) as Record<string, unknown>
28
37
  const candidate =
@@ -120,6 +129,38 @@ function buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResul
120
129
  netSubtotalBeforeDiscount,
121
130
  )
122
131
  const netSubtotal = Math.max(netSubtotalBeforeDiscount - discountTotal, 0)
132
+ // Unlike totalGrossAmount below, a supplied totalNetAmount is never honoured
133
+ // verbatim — net always comes from unitPriceNet/discount so it stays
134
+ // internally consistent with them. A caller-supplied value is still
135
+ // reconciled against the computed one so a divergence (e.g. a mis-read
136
+ // discount) surfaces instead of being silently discarded (#5644).
137
+ //
138
+ // Only a caller's value is reconciled: a snapshot rebuilt from a persisted
139
+ // row (`totalsFromStoredRow`) carries the engine's own previous output, and
140
+ // on a row the discount contract still has to heal that value is *supposed*
141
+ // to differ from the recomputed net. Warning about it would drown the caller
142
+ // signal this exists for in one line per line per recalculation.
143
+ if (line.totalsFromStoredRow !== true && line.totalNetAmount !== null && line.totalNetAmount !== undefined) {
144
+ const computedNetAmount = round(netSubtotal)
145
+ const suppliedNetAmount = toNumber(line.totalNetAmount, NaN)
146
+ if (!Number.isFinite(suppliedNetAmount)) {
147
+ // Falling back to the computed value here would compare equal and log
148
+ // nothing — the same silent discard #5644 exists to end.
149
+ logger.warn('Sales line totalNetAmount is not a finite number; the computed value is used', {
150
+ lineId: line.id ?? null,
151
+ productId: line.productId ?? null,
152
+ suppliedTotalNetAmount: line.totalNetAmount,
153
+ computedNetAmount,
154
+ })
155
+ } else if (Math.abs(round(suppliedNetAmount) - computedNetAmount) > NET_RECONCILIATION_TOLERANCE) {
156
+ logger.warn('Sales line totalNetAmount does not match the computed net amount; the computed value is used', {
157
+ lineId: line.id ?? null,
158
+ productId: line.productId ?? null,
159
+ suppliedTotalNetAmount: round(suppliedNetAmount),
160
+ computedNetAmount,
161
+ })
162
+ }
163
+ }
123
164
  const explicitTaxAmount = line.taxAmount !== null && line.taxAmount !== undefined
124
165
  let taxAmount = explicitTaxAmount
125
166
  ? toNumber(line.taxAmount, 0)
@@ -39,6 +39,11 @@ function mapPersistedLine(line: SalesOrderLine | SalesQuoteLine): SalesLineSnaps
39
39
  discountPercent: toNumeric(line.discountPercent),
40
40
  taxRate: toNumeric(line.taxRate),
41
41
  taxAmount: toNumeric(line.taxAmount),
42
+ // The totals below are the engine's own previous output read back off the
43
+ // row, not something a caller asserted, so they are not reconciled against
44
+ // the recomputed net (#5644) — on a legacy row that divergence is the
45
+ // discount contract healing itself, not a caller mistake.
46
+ totalsFromStoredRow: true,
42
47
  totalNetAmount: toNumeric(line.totalNetAmount),
43
48
  totalGrossAmount: toNumeric(line.totalGrossAmount),
44
49
  configuration: line.configuration ? cloneJson(line.configuration) : null,
@@ -96,3 +101,21 @@ export function resolveUpsertDiscountFields(
96
101
  discountAmountFromStoredRow: existingSnapshot != null,
97
102
  }
98
103
  }
104
+
105
+ /**
106
+ * Decide whether the `totalNetAmount` an upsert payload ends up carrying is a
107
+ * caller assertion or a value that came back off the stored row.
108
+ *
109
+ * The upsert merges caller input over the existing snapshot, so the merged
110
+ * total has two possible origins and only the caller one is worth reconciling
111
+ * against the recomputed net (#5644): a value read back off the row is what the
112
+ * engine itself wrote last time, and on a legacy row it is exactly what
113
+ * recalculation is supposed to heal.
114
+ */
115
+ export function resolveUpsertTotalsOrigin(
116
+ callerTotalNetAmount: number | null | undefined,
117
+ existingSnapshot: Pick<SalesLineSnapshot, 'totalNetAmount'> | null | undefined,
118
+ ): Pick<SalesLineSnapshot, 'totalsFromStoredRow'> {
119
+ if (callerTotalNetAmount !== null && callerTotalNetAmount !== undefined) return {}
120
+ return existingSnapshot != null ? { totalsFromStoredRow: true } : {}
121
+ }
@@ -73,6 +73,15 @@ export type SalesLineSnapshot = {
73
73
  discountPercent?: number | null
74
74
  taxRate?: number | null
75
75
  taxAmount?: number | null
76
+ /**
77
+ * Set by entity-to-snapshot mappers ONLY. Marks `totalNetAmount` /
78
+ * `totalGrossAmount` as reconstructed from a persisted row, so they are the
79
+ * engine's own previous output rather than a caller assertion. That is what
80
+ * keeps the #5644 reconciliation a *caller* signal: a stored net is expected
81
+ * to diverge on a row the discount contract heals on the next pass. Never
82
+ * persisted; never accepted from a request.
83
+ */
84
+ totalsFromStoredRow?: boolean
76
85
  totalNetAmount?: number | null
77
86
  totalGrossAmount?: number | null
78
87
  configuration?: Record<string, unknown> | null
@@ -0,0 +1,21 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ import { declareQueryIndexReindex } from '@open-mercato/shared/lib/query/migration-reindex';
3
+
4
+ /**
5
+ * Catch-up reindex for `Migration20260428102318`, which renamed three seeded
6
+ * `workflow_definitions.workflow_id` values in raw SQL and never notified the query index. On
7
+ * every install that already applied it, `entity_indexes.doc` for `workflows:workflow_definition`
8
+ * still resolves those records by the pre-rename identifier — `checkout_simple_v1` rather than
9
+ * `workflows.checkout-demo`, and so on — which is exactly the identifier the rename removed.
10
+ *
11
+ * This migration executes no SQL — the declaration below is its entire payload.
12
+ */
13
+ export const queryIndexReindexEntityTypes = declareQueryIndexReindex([
14
+ 'workflows:workflow_definition',
15
+ ]);
16
+
17
+ export class Migration20260901120000_reindex_workflow_definitions extends Migration {
18
+ override up(): void | Promise<void> {}
19
+
20
+ override down(): void | Promise<void> {}
21
+ }