@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.5

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 (237) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bootstrap.js +46 -6
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/organization/index.js +2 -0
  5. package/dist/generated/entities/organization/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/crmFixtures.js +4 -0
  9. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +2 -0
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/lib/access.js +18 -0
  13. package/dist/modules/attachments/lib/access.js.map +2 -2
  14. package/dist/modules/audit_logs/data/entities.js +2 -1
  15. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  16. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  17. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  18. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  19. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  20. package/dist/modules/auth/api/admin/nav.js +9 -0
  21. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  22. package/dist/modules/auth/api/login.js +4 -13
  23. package/dist/modules/auth/api/login.js.map +2 -2
  24. package/dist/modules/auth/data/entities.js +3 -1
  25. package/dist/modules/auth/data/entities.js.map +2 -2
  26. package/dist/modules/auth/lib/backendChrome.js +35 -2
  27. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  28. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  29. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  30. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  31. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  32. package/dist/modules/auth/services/authService.js +5 -3
  33. package/dist/modules/auth/services/authService.js.map +2 -2
  34. package/dist/modules/auth/services/rbacService.js +3 -2
  35. package/dist/modules/auth/services/rbacService.js.map +2 -2
  36. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  37. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  38. package/dist/modules/customers/api/deals/route.js +43 -2
  39. package/dist/modules/customers/api/deals/route.js.map +2 -2
  40. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  41. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  45. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  46. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  47. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  48. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  49. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  50. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  51. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  52. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  53. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  54. package/dist/modules/customers/cli.js +15 -9
  55. package/dist/modules/customers/cli.js.map +2 -2
  56. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  57. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  58. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  59. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  60. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  61. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  62. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  63. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  64. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  65. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  66. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  67. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  68. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  69. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  70. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  71. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  72. package/dist/modules/directory/api/organizations/route.js +7 -0
  73. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  74. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  75. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  76. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  77. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  78. package/dist/modules/directory/commands/organizations.js +8 -1
  79. package/dist/modules/directory/commands/organizations.js.map +2 -2
  80. package/dist/modules/directory/data/entities.js +3 -0
  81. package/dist/modules/directory/data/entities.js.map +2 -2
  82. package/dist/modules/directory/data/validators.js +9 -0
  83. package/dist/modules/directory/data/validators.js.map +2 -2
  84. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  85. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  86. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  87. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  88. package/dist/modules/directory/utils/organizationScope.js +59 -27
  89. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  90. package/dist/modules/entities/api/definitions.batch.js +2 -1
  91. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  92. package/dist/modules/entities/api/entities.js +7 -0
  93. package/dist/modules/entities/api/entities.js.map +2 -2
  94. package/dist/modules/entities/api/records.js +26 -15
  95. package/dist/modules/entities/api/records.js.map +2 -2
  96. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  97. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  98. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  99. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  100. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  101. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  102. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  103. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  104. package/dist/modules/query_index/data/entities.js +2 -1
  105. package/dist/modules/query_index/data/entities.js.map +2 -2
  106. package/dist/modules/query_index/lib/engine.js +4 -2
  107. package/dist/modules/query_index/lib/engine.js.map +2 -2
  108. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  109. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  110. package/dist/modules/sales/commands/documents.js +7 -5
  111. package/dist/modules/sales/commands/documents.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  113. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  114. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  115. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  116. package/dist/modules/staff/api/team-members.js +9 -2
  117. package/dist/modules/staff/api/team-members.js.map +2 -2
  118. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  119. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  120. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  121. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  122. package/dist/modules/staff/commands/team-members.js +1 -1
  123. package/dist/modules/staff/commands/team-members.js.map +2 -2
  124. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  125. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  126. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  127. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  128. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  129. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  130. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  131. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  132. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  133. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  134. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  135. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  136. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  137. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  138. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  139. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  140. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  141. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  142. package/generated/entities/organization/index.ts +1 -0
  143. package/generated/entity-fields-registry.ts +1 -0
  144. package/package.json +11 -12
  145. package/src/bootstrap.ts +65 -7
  146. package/src/helpers/integration/crmFixtures.ts +21 -1
  147. package/src/modules/attachments/AGENTS.md +79 -0
  148. package/src/modules/attachments/api/route.ts +2 -0
  149. package/src/modules/attachments/lib/access.ts +36 -0
  150. package/src/modules/audit_logs/data/entities.ts +1 -0
  151. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  152. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  153. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  154. package/src/modules/auth/api/admin/nav.ts +9 -0
  155. package/src/modules/auth/api/login.ts +13 -13
  156. package/src/modules/auth/data/entities.ts +2 -0
  157. package/src/modules/auth/i18n/de.json +0 -1
  158. package/src/modules/auth/i18n/en.json +0 -1
  159. package/src/modules/auth/i18n/es.json +0 -1
  160. package/src/modules/auth/i18n/pl.json +0 -1
  161. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  162. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  163. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -0
  164. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  165. package/src/modules/auth/services/authService.ts +24 -4
  166. package/src/modules/auth/services/rbacService.ts +11 -2
  167. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  168. package/src/modules/customers/api/deals/route.ts +51 -2
  169. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  170. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  171. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  172. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  173. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  174. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  175. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  176. package/src/modules/customers/cli.ts +15 -15
  177. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  178. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  179. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  180. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  181. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  182. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  183. package/src/modules/customers/i18n/de.json +43 -0
  184. package/src/modules/customers/i18n/en.json +43 -0
  185. package/src/modules/customers/i18n/es.json +43 -0
  186. package/src/modules/customers/i18n/pl.json +43 -0
  187. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  188. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  189. package/src/modules/directory/api/organizations/route.ts +7 -0
  190. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  191. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  192. package/src/modules/directory/commands/organizations.ts +9 -1
  193. package/src/modules/directory/data/entities.ts +3 -0
  194. package/src/modules/directory/data/validators.ts +12 -0
  195. package/src/modules/directory/i18n/de.json +21 -0
  196. package/src/modules/directory/i18n/en.json +21 -0
  197. package/src/modules/directory/i18n/es.json +21 -0
  198. package/src/modules/directory/i18n/pl.json +21 -0
  199. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  200. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  201. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  202. package/src/modules/directory/utils/organizationScope.ts +85 -30
  203. package/src/modules/entities/api/definitions.batch.ts +11 -7
  204. package/src/modules/entities/api/entities.ts +11 -0
  205. package/src/modules/entities/api/records.ts +46 -25
  206. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  207. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  208. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  209. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  210. package/src/modules/entities/i18n/de.json +1 -0
  211. package/src/modules/entities/i18n/en.json +1 -0
  212. package/src/modules/entities/i18n/es.json +1 -0
  213. package/src/modules/entities/i18n/pl.json +1 -0
  214. package/src/modules/query_index/data/entities.ts +1 -0
  215. package/src/modules/query_index/lib/engine.ts +11 -5
  216. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  217. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  218. package/src/modules/sales/commands/documents.ts +7 -5
  219. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  220. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  221. package/src/modules/staff/api/team-members.ts +9 -2
  222. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  223. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  224. package/src/modules/staff/commands/team-members.ts +5 -2
  225. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  226. package/src/modules/staff/i18n/de.json +1 -0
  227. package/src/modules/staff/i18n/en.json +1 -0
  228. package/src/modules/staff/i18n/es.json +1 -0
  229. package/src/modules/staff/i18n/pl.json +1 -0
  230. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  231. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  232. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  233. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  234. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  235. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  236. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  237. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -0,0 +1,159 @@
1
+ import type { RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
2
+
3
+ /**
4
+ * Quarter / period helpers for the deals KPI summary. Computed in **UTC** so the
5
+ * window boundaries are stable regardless of the server timezone — `expected_close_at`
6
+ * is a bare `Date` (date-only) while `created_at` / `updated_at` are timestamptz, and
7
+ * mixing local-time boundaries would misbucket deals near a quarter edge.
8
+ */
9
+
10
+ export type PeriodWindow = {
11
+ /** Inclusive lower bound (UTC). */
12
+ start: Date
13
+ /** Exclusive upper bound (UTC). */
14
+ end: Date
15
+ }
16
+
17
+ export type TrailingMonth = {
18
+ /** Inclusive lower bound (UTC) of the month bucket. */
19
+ start: Date
20
+ /** 'YYYY-MM' label for the bucket. */
21
+ label: string
22
+ }
23
+
24
+ export type DeltaDirection = 'up' | 'down' | 'unchanged'
25
+
26
+ export type Delta = {
27
+ value: number
28
+ direction: DeltaDirection
29
+ }
30
+
31
+ export type CurrencySum = {
32
+ currency: string
33
+ total: number
34
+ }
35
+
36
+ export type ConvertedSums = {
37
+ total: number
38
+ convertedAll: boolean
39
+ missingRateCurrencies: string[]
40
+ }
41
+
42
+ function startOfQuarterUtc(year: number, quarterStartMonth: number): Date {
43
+ return new Date(Date.UTC(year, quarterStartMonth, 1, 0, 0, 0, 0))
44
+ }
45
+
46
+ /**
47
+ * Returns the [start, end) window of the calendar quarter that contains `now`,
48
+ * in UTC. Quarters are fixed 3-month blocks: Jan–Mar, Apr–Jun, Jul–Sep, Oct–Dec.
49
+ * `end` is exclusive (the start of the next quarter).
50
+ */
51
+ export function getQuarterWindow(now: Date): PeriodWindow {
52
+ const year = now.getUTCFullYear()
53
+ const quarterIndex = Math.floor(now.getUTCMonth() / 3)
54
+ const startMonth = quarterIndex * 3
55
+ const start = startOfQuarterUtc(year, startMonth)
56
+ const end = startOfQuarterUtc(year, startMonth + 3)
57
+ return { start, end }
58
+ }
59
+
60
+ /**
61
+ * Returns the [start, end) window of the quarter immediately preceding the one
62
+ * that contains `now`, in UTC. `end` is exclusive and equals the current quarter's start.
63
+ */
64
+ export function getPreviousQuarterWindow(now: Date): PeriodWindow {
65
+ const current = getQuarterWindow(now)
66
+ const start = startOfQuarterUtc(current.start.getUTCFullYear(), current.start.getUTCMonth() - 3)
67
+ return { start, end: current.start }
68
+ }
69
+
70
+ function monthLabel(year: number, monthIndex: number): string {
71
+ const month = monthIndex + 1
72
+ return `${year}-${month < 10 ? `0${month}` : month}`
73
+ }
74
+
75
+ /**
76
+ * Returns `count` trailing month buckets ending with the month that contains `now`,
77
+ * ordered oldest → newest. Each bucket exposes its UTC start and a 'YYYY-MM' label.
78
+ * Used to drive the win-rate sparkline series.
79
+ */
80
+ export function getTrailingMonths(now: Date, count: number): TrailingMonth[] {
81
+ const buckets: TrailingMonth[] = []
82
+ const baseYear = now.getUTCFullYear()
83
+ const baseMonth = now.getUTCMonth()
84
+ for (let offset = count - 1; offset >= 0; offset -= 1) {
85
+ const start = new Date(Date.UTC(baseYear, baseMonth - offset, 1, 0, 0, 0, 0))
86
+ buckets.push({ start, label: monthLabel(start.getUTCFullYear(), start.getUTCMonth()) })
87
+ }
88
+ return buckets
89
+ }
90
+
91
+ /**
92
+ * Percentage change of `current` relative to `previous`, rounded to whole percent.
93
+ * When there is no previous-period baseline, avoid reporting artificial growth.
94
+ */
95
+ export function computeDelta(current: number, previous: number): Delta {
96
+ if (previous === 0) {
97
+ return { value: 0, direction: 'unchanged' }
98
+ }
99
+ const change = ((current - previous) / Math.abs(previous)) * 100
100
+ const value = Math.round(change)
101
+ if (value > 0) return { value, direction: 'up' }
102
+ if (value < 0) return { value, direction: 'down' }
103
+ return { value: 0, direction: 'unchanged' }
104
+ }
105
+
106
+ function extractRate(result: RateResult | undefined): number | null {
107
+ if (!result || result.rates.length === 0) return null
108
+ const rate = Number(result.rates[0].rate)
109
+ if (!Number.isFinite(rate) || rate <= 0) return null
110
+ return rate
111
+ }
112
+
113
+ /**
114
+ * Converts per-currency sums to the tenant base currency, mirroring the conversion
115
+ * logic in `api/deals/aggregate/route.ts`:
116
+ * - the base currency stays 1:1,
117
+ * - other currencies multiply by the rate from `rates` (keyed `"FROM/BASE"`),
118
+ * - a currency with no usable rate is excluded from `total` and flagged in
119
+ * `missingRateCurrencies` (with `convertedAll: false`).
120
+ *
121
+ * When `baseCode` is null there is no base currency configured, so nothing can be
122
+ * converted: every present currency is reported as missing and `convertedAll` is false.
123
+ *
124
+ * `rates` accepts the `Map<string, RateResult>` shape returned by
125
+ * `exchangeRateService.getRates` so callers can pass its output directly.
126
+ */
127
+ export function convertSumsToBase(
128
+ perCurrency: CurrencySum[],
129
+ baseCode: string | null,
130
+ rates: Map<string, RateResult>,
131
+ ): ConvertedSums {
132
+ if (!baseCode) {
133
+ const missing = Array.from(
134
+ new Set(perCurrency.map((entry) => entry.currency).filter((code): code is string => Boolean(code))),
135
+ )
136
+ return { total: 0, convertedAll: missing.length === 0, missingRateCurrencies: missing }
137
+ }
138
+
139
+ let total = 0
140
+ let convertedAll = true
141
+ const missingRateCurrencies: string[] = []
142
+ for (const entry of perCurrency) {
143
+ if (!entry.currency) continue
144
+ if (entry.currency === baseCode) {
145
+ total += entry.total
146
+ continue
147
+ }
148
+ const rate = extractRate(rates.get(`${entry.currency}/${baseCode}`))
149
+ if (rate !== null) {
150
+ total += entry.total * rate
151
+ } else {
152
+ convertedAll = false
153
+ if (!missingRateCurrencies.includes(entry.currency)) {
154
+ missingRateCurrencies.push(entry.currency)
155
+ }
156
+ }
157
+ }
158
+ return { total: Math.round(total), convertedAll, missingRateCurrencies }
159
+ }
@@ -0,0 +1,238 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { runWithCacheTenant } from '@open-mercato/cache'
5
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
8
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
9
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
+ import { Organization } from '@open-mercato/core/modules/directory/data/entities'
13
+ import { organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'
14
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
15
+ import '@open-mercato/core/modules/directory/commands/organizations'
16
+
17
+ export const metadata = {
18
+ GET: { requireAuth: true, requireFeatures: ['directory.organizations.view'] },
19
+ PUT: { requireAuth: true, requireFeatures: ['directory.organizations.manage'] },
20
+ }
21
+
22
+ const brandingResponseSchema = z.object({
23
+ organizationId: z.string().uuid(),
24
+ organizationName: z.string(),
25
+ tenantId: z.string().uuid(),
26
+ logoUrl: z.string().nullable(),
27
+ })
28
+
29
+ const brandingUpdateSchema = z.object({
30
+ logoUrl: organizationUpdateSchema.shape.logoUrl,
31
+ })
32
+
33
+ const errorSchema = z.object({
34
+ error: z.string(),
35
+ })
36
+
37
+ type RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>
38
+
39
+ function buildCommandContext(
40
+ container: RequestContainer,
41
+ auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,
42
+ req: Request,
43
+ organizationId: string,
44
+ tenantId: string,
45
+ ): CommandRuntimeContext {
46
+ return {
47
+ container,
48
+ auth,
49
+ organizationScope: {
50
+ selectedId: organizationId,
51
+ filterIds: [organizationId],
52
+ allowedIds: null,
53
+ tenantId,
54
+ },
55
+ selectedOrganizationId: organizationId,
56
+ organizationIds: [organizationId],
57
+ request: req,
58
+ }
59
+ }
60
+
61
+ async function resolveCurrentOrganization(req: Request) {
62
+ const { translate } = await resolveTranslations()
63
+ const auth = await getAuthFromRequest(req)
64
+ if (!auth?.sub) {
65
+ return {
66
+ response: NextResponse.json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 }),
67
+ }
68
+ }
69
+
70
+ const container = await createRequestContainer()
71
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
72
+ const organizationId = scope.selectedId ?? auth.orgId ?? null
73
+ const tenantId = scope.tenantId ?? auth.tenantId ?? null
74
+ if (!organizationId || !tenantId) {
75
+ return {
76
+ response: NextResponse.json(
77
+ {
78
+ error: translate(
79
+ 'directory.branding.errors.organizationRequired',
80
+ 'Select a single organization before changing sidebar branding.',
81
+ ),
82
+ },
83
+ { status: 400 },
84
+ ),
85
+ }
86
+ }
87
+
88
+ const em = container.resolve('em') as EntityManager
89
+ const organization = await findOneWithDecryption(
90
+ em,
91
+ Organization,
92
+ { id: organizationId, tenant: tenantId, deletedAt: null },
93
+ { populate: ['tenant'] },
94
+ { tenantId, organizationId },
95
+ )
96
+ if (!organization) {
97
+ return {
98
+ response: NextResponse.json(
99
+ { error: translate('directory.branding.errors.notFound', 'Organization not found') },
100
+ { status: 404 },
101
+ ),
102
+ }
103
+ }
104
+
105
+ return { auth, container, organization, organizationId, tenantId, translate }
106
+ }
107
+
108
+ function toResponsePayload(organization: Organization, tenantId: string) {
109
+ return {
110
+ organizationId: String(organization.id),
111
+ organizationName: organization.name,
112
+ tenantId,
113
+ logoUrl: organization.logoUrl ?? null,
114
+ }
115
+ }
116
+
117
+ async function invalidateSidebarBrandingCache(container: RequestContainer, organizationId: string, tenantId: string) {
118
+ try {
119
+ const cache = container.resolve('cache') as {
120
+ deleteByTags?: (tags: string[]) => Promise<unknown>
121
+ } | null
122
+ await runWithCacheTenant(tenantId, () =>
123
+ cache?.deleteByTags?.([
124
+ `nav:sidebar:organization:${organizationId}`,
125
+ `nav:sidebar:tenant:${tenantId}`,
126
+ ]),
127
+ )
128
+ } catch {
129
+ // Cache invalidation is best-effort; the persisted branding is the source of truth.
130
+ }
131
+ }
132
+
133
+ export async function GET(req: Request) {
134
+ const resolved = await resolveCurrentOrganization(req)
135
+ if ('response' in resolved) return resolved.response
136
+
137
+ return NextResponse.json(toResponsePayload(resolved.organization, resolved.tenantId))
138
+ }
139
+
140
+ export async function PUT(req: Request) {
141
+ const resolved = await resolveCurrentOrganization(req)
142
+ if ('response' in resolved) return resolved.response
143
+
144
+ let body: unknown
145
+ try {
146
+ body = await req.json()
147
+ } catch {
148
+ return NextResponse.json(
149
+ { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },
150
+ { status: 422 },
151
+ )
152
+ }
153
+ if (!body || typeof body !== 'object' || !Object.prototype.hasOwnProperty.call(body, 'logoUrl')) {
154
+ return NextResponse.json(
155
+ { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },
156
+ { status: 422 },
157
+ )
158
+ }
159
+
160
+ const parsed = brandingUpdateSchema.safeParse(body)
161
+ if (!parsed.success) {
162
+ return NextResponse.json(
163
+ {
164
+ error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.'),
165
+ issues: parsed.error.issues,
166
+ },
167
+ { status: 422 },
168
+ )
169
+ }
170
+
171
+ try {
172
+ const commandBus = resolved.container.resolve('commandBus') as CommandBus
173
+ const ctx = buildCommandContext(
174
+ resolved.container,
175
+ resolved.auth,
176
+ req,
177
+ resolved.organizationId,
178
+ resolved.tenantId,
179
+ )
180
+ const { result } = await commandBus.execute<Record<string, unknown>, Organization>(
181
+ 'directory.organizations.update',
182
+ {
183
+ input: {
184
+ id: resolved.organizationId,
185
+ tenantId: resolved.tenantId,
186
+ logoUrl: parsed.data.logoUrl ?? null,
187
+ },
188
+ ctx,
189
+ },
190
+ )
191
+ await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)
192
+ return NextResponse.json(toResponsePayload(result, resolved.tenantId))
193
+ } catch (err) {
194
+ if (isCrudHttpError(err)) {
195
+ return NextResponse.json(err.body, { status: err.status })
196
+ }
197
+ console.error('directory.organization-branding.update failed', err)
198
+ return NextResponse.json(
199
+ { error: resolved.translate('directory.branding.errors.save', 'Failed to update organization branding.') },
200
+ { status: 400 },
201
+ )
202
+ }
203
+ }
204
+
205
+ export const openApi: OpenApiRouteDoc = {
206
+ tag: 'Directory',
207
+ summary: 'Current organization branding',
208
+ methods: {
209
+ GET: {
210
+ summary: 'Read sidebar branding for the selected organization',
211
+ description: 'Returns the logo URL used by the backend sidebar for the currently selected organization.',
212
+ responses: [
213
+ { status: 200, description: 'Organization branding', schema: brandingResponseSchema },
214
+ ],
215
+ errors: [
216
+ { status: 400, description: 'A concrete organization scope is required', schema: errorSchema },
217
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
218
+ { status: 404, description: 'Organization not found', schema: errorSchema },
219
+ ],
220
+ },
221
+ PUT: {
222
+ summary: 'Update sidebar branding for the selected organization',
223
+ description: 'Stores an external image URL or an internal attachment image URL as the selected organization logo.',
224
+ requestBody: {
225
+ contentType: 'application/json',
226
+ schema: brandingUpdateSchema,
227
+ },
228
+ responses: [
229
+ { status: 200, description: 'Updated organization branding', schema: brandingResponseSchema },
230
+ ],
231
+ errors: [
232
+ { status: 400, description: 'Save failed', schema: errorSchema },
233
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
234
+ { status: 422, description: 'Invalid logo URL', schema: errorSchema },
235
+ ],
236
+ },
237
+ },
238
+ }
@@ -250,6 +250,7 @@ export async function GET(req: Request) {
250
250
  const items = orgs.map((org) => ({
251
251
  id: stringId(org.id),
252
252
  name: org.name,
253
+ logoUrl: org.logoUrl ?? null,
253
254
  parentId: org.parentId ?? null,
254
255
  tenantId: tenantId,
255
256
  isActive: !!org.isActive,
@@ -341,8 +342,10 @@ export async function GET(req: Request) {
341
342
  }
342
343
 
343
344
  const slugByOrgId = new Map<string, string | null>()
345
+ const logoUrlByOrgId = new Map<string, string | null>()
344
346
  for (const org of allOrgs) {
345
347
  slugByOrgId.set(String(org.id), org.slug ?? null)
348
+ logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
346
349
  }
347
350
 
348
351
  const tenantIds = Array.from(byTenant.keys())
@@ -426,6 +429,7 @@ export async function GET(req: Request) {
426
429
  id: node.id,
427
430
  name: node.name,
428
431
  slug: slugByOrgId.get(recordId) ?? null,
432
+ logoUrl: logoUrlByOrgId.get(recordId) ?? null,
429
433
  tenantId: tid,
430
434
  tenantName: tenantNameMap[tid] ?? tid,
431
435
  parentId: node.parentId,
@@ -467,9 +471,11 @@ export async function GET(req: Request) {
467
471
  const orgs = await em.find(Organization, orgListFilter, { orderBy: { name: 'ASC' } })
468
472
  const hierarchy = computeHierarchyForOrganizations(orgs, tenantId)
469
473
  const slugByOrgId = new Map<string, string | null>()
474
+ const logoUrlByOrgId = new Map<string, string | null>()
470
475
  const updatedAtByOrgId = new Map<string, string | null>()
471
476
  for (const org of orgs) {
472
477
  slugByOrgId.set(String(org.id), org.slug ?? null)
478
+ logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
473
479
  updatedAtByOrgId.set(String(org.id), org.updatedAt instanceof Date ? org.updatedAt.toISOString() : null)
474
480
  }
475
481
 
@@ -533,6 +539,7 @@ export async function GET(req: Request) {
533
539
  id: node.id,
534
540
  name: node.name,
535
541
  slug: slugByOrgId.get(recordId) ?? null,
542
+ logoUrl: logoUrlByOrgId.get(recordId) ?? null,
536
543
  updatedAt: updatedAtByOrgId.get(recordId) ?? null,
537
544
  tenantId: node.tenantId,
538
545
  tenantName,
@@ -0,0 +1,24 @@
1
+ import React from 'react'
2
+
3
+ const brandingIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
7
+ React.createElement('circle', { cx: 8, cy: 10, r: 1.5 }),
8
+ React.createElement('path', { d: 'm21 15-5-5L5 21' }),
9
+ )
10
+
11
+ export const metadata = {
12
+ requireAuth: true,
13
+ requireFeatures: ['directory.organizations.manage'],
14
+ pageTitle: 'Organization branding',
15
+ pageTitleKey: 'directory.branding.nav',
16
+ pageGroup: 'Directory',
17
+ pageGroupKey: 'settings.sections.directory',
18
+ pageOrder: 0,
19
+ icon: brandingIcon,
20
+ pageContext: 'settings' as const,
21
+ breadcrumb: [{ label: 'Organization branding', labelKey: 'directory.branding.nav' }],
22
+ }
23
+
24
+ export default metadata