@open-mercato/core 0.6.7-develop.6751.1.ac823a3d26 → 0.6.7-develop.6758.1.697eade236

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 (100) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/route.js +16 -2
  3. package/dist/modules/attachments/api/route.js.map +2 -2
  4. package/dist/modules/attachments/lib/upload-limits.js +33 -0
  5. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/currencies/di.js +4 -0
  9. package/dist/modules/currencies/di.js.map +2 -2
  10. package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
  11. package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
  12. package/dist/modules/customers/analytics.js +3 -1
  13. package/dist/modules/customers/analytics.js.map +2 -2
  14. package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
  15. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  16. package/dist/modules/customers/api/deals/summary/route.js +5 -4
  17. package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
  18. package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
  19. package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
  20. package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
  21. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  22. package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
  23. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  24. package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
  25. package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
  26. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
  27. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
  28. package/dist/modules/dashboards/lib/aggregations.js +17 -0
  29. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  30. package/dist/modules/dashboards/lib/formatters.js +84 -11
  31. package/dist/modules/dashboards/lib/formatters.js.map +2 -2
  32. package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
  33. package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
  34. package/dist/modules/dashboards/services/widgetDataService.js +86 -6
  35. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  36. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
  37. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
  38. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
  39. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
  40. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
  41. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  42. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
  43. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
  44. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
  45. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
  46. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
  47. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
  48. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
  49. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
  50. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
  51. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
  52. package/dist/modules/notifications/api/route.js +62 -3
  53. package/dist/modules/notifications/api/route.js.map +2 -2
  54. package/dist/modules/sales/analytics.js +7 -3
  55. package/dist/modules/sales/analytics.js.map +2 -2
  56. package/dist/modules/sales/api/document-history/route.js +2 -1
  57. package/dist/modules/sales/api/document-history/route.js.map +2 -2
  58. package/dist/modules/sales/lib/historyHelpers.js +69 -1
  59. package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
  60. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
  61. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  62. package/package.json +7 -7
  63. package/src/modules/attachments/api/route.ts +16 -2
  64. package/src/modules/attachments/lib/upload-limits.ts +35 -0
  65. package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
  66. package/src/modules/currencies/di.ts +4 -1
  67. package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
  68. package/src/modules/customers/analytics.ts +2 -0
  69. package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
  70. package/src/modules/customers/api/deals/summary/route.ts +5 -4
  71. package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
  72. package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
  73. package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
  74. package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
  75. package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
  76. package/src/modules/dashboards/i18n/de.json +2 -0
  77. package/src/modules/dashboards/i18n/en.json +2 -0
  78. package/src/modules/dashboards/i18n/es.json +2 -0
  79. package/src/modules/dashboards/i18n/pl.json +2 -0
  80. package/src/modules/dashboards/lib/aggregations.ts +35 -0
  81. package/src/modules/dashboards/lib/formatters.ts +137 -12
  82. package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
  83. package/src/modules/dashboards/services/widgetDataService.ts +110 -4
  84. package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
  85. package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
  86. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
  87. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
  88. package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
  89. package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
  90. package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
  91. package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
  92. package/src/modules/notifications/api/route.ts +88 -3
  93. package/src/modules/sales/analytics.ts +4 -0
  94. package/src/modules/sales/api/document-history/route.ts +1 -0
  95. package/src/modules/sales/i18n/de.json +19 -0
  96. package/src/modules/sales/i18n/en.json +19 -0
  97. package/src/modules/sales/i18n/es.json +19 -0
  98. package/src/modules/sales/i18n/pl.json +19 -0
  99. package/src/modules/sales/lib/historyHelpers.ts +88 -1
  100. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +64 -3
@@ -37,7 +37,9 @@ import {
37
37
  sanitizeUploadedFileName,
38
38
  } from '../lib/security'
39
39
  import {
40
+ isMultipartUploadLimitError,
40
41
  isMultipartRequestWithinUploadLimit,
42
+ parseMultipartFormDataWithinUploadLimit,
41
43
  resolveAttachmentMaxBytes,
42
44
  willExceedAttachmentTenantQuota,
43
45
  } from '../lib/upload-limits'
@@ -283,10 +285,22 @@ export async function POST(req: Request) {
283
285
  return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
284
286
  }
285
287
  if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
286
- return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
288
+ return NextResponse.json({
289
+ error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
290
+ }, { status: 413 })
287
291
  }
288
292
 
289
- const form = await req.formData()
293
+ let form: FormData
294
+ try {
295
+ form = await parseMultipartFormDataWithinUploadLimit(req)
296
+ } catch (error) {
297
+ if (isMultipartUploadLimitError(error)) {
298
+ return NextResponse.json({
299
+ error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
300
+ }, { status: 413 })
301
+ }
302
+ throw error
303
+ }
290
304
  const formPayload = buildFormPayload(form)
291
305
  const customFieldValues = splitCustomFieldPayload(formPayload).custom
292
306
  const entityId = String(form.get('entityId') || '')
@@ -46,6 +46,41 @@ export function isMultipartRequestWithinUploadLimit(contentLengthHeader: string
46
46
  return contentLength <= (resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES)
47
47
  }
48
48
 
49
+ export class MultipartUploadLimitError extends Error {
50
+ constructor() {
51
+ super('Multipart request exceeds the maximum upload size.')
52
+ this.name = 'MultipartUploadLimitError'
53
+ }
54
+ }
55
+
56
+ export function isMultipartUploadLimitError(error: unknown): error is MultipartUploadLimitError {
57
+ return error instanceof MultipartUploadLimitError
58
+ }
59
+
60
+ export async function parseMultipartFormDataWithinUploadLimit(request: Request): Promise<FormData> {
61
+ if (!isMultipartRequestWithinUploadLimit(request.headers.get('content-length'))) {
62
+ throw new MultipartUploadLimitError()
63
+ }
64
+
65
+ if (!request.body) return request.formData()
66
+
67
+ const maxBytes = resolveDefaultAttachmentMaxUploadBytes() + MULTIPART_CONTENT_LENGTH_OVERHEAD_BYTES
68
+ let consumedBytes = 0
69
+ const boundedBody = request.body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
70
+ transform(chunk, controller) {
71
+ consumedBytes += chunk.byteLength
72
+ if (consumedBytes > maxBytes) {
73
+ controller.error(new MultipartUploadLimitError())
74
+ return
75
+ }
76
+ controller.enqueue(chunk)
77
+ },
78
+ }))
79
+ const contentType = request.headers.get('content-type')
80
+ const headers = contentType ? { 'content-type': contentType } : undefined
81
+ return new Response(boundedBody, { headers }).formData()
82
+ }
83
+
49
84
  export function willExceedAttachmentTenantQuota(currentUsageBytes: number, incomingFileBytes: number): boolean {
50
85
  return (currentUsageBytes + incomingFileBytes) > resolveAttachmentTenantQuotaBytes()
51
86
  }
@@ -5,7 +5,14 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import type { CommandBus } from '@open-mercato/shared/lib/commands'
7
7
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
8
- import { ChannelThreadMapping, ExternalConversation, MessageChannelLink } from '../../../data/entities'
8
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
+ import {
10
+ ChannelThreadMapping,
11
+ CommunicationChannel,
12
+ ExternalConversation,
13
+ MessageChannelLink,
14
+ } from '../../../data/entities'
15
+ import { ChannelAccessDeniedError, assertCanManageChannel } from '../../../lib/access-control'
9
16
  import {
10
17
  COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,
11
18
  type ConnectCredentialChannelInput,
@@ -35,6 +42,13 @@ import {
35
42
  * customers link-channel-message subscriber runs against real Postgres. Enables
36
43
  * the inbound auto-link tests (TC-CRM-EMAIL-002..005).
37
44
  */
45
+ type RbacServiceLike = {
46
+ loadAcl: (
47
+ userId: string,
48
+ scope: { tenantId: string | null; organizationId: string | null },
49
+ ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
50
+ }
51
+
38
52
  export const metadata = {
39
53
  path: '/communication_channels/test-seed',
40
54
  POST: {
@@ -164,6 +178,55 @@ export async function POST(req: Request): Promise<Response> {
164
178
  const em = (container.resolve('em') as EntityManager).fork()
165
179
  const providerKey = body.providerKey ?? TEST_SEED_PROVIDER_KEY
166
180
 
181
+ // `channelId` is caller-supplied, so confirm it names a channel this tenant/org
182
+ // actually owns before seeding rows that reference it. Mirrors the ownership
183
+ // lookup every other per-channel route performs (see channels/[id]/test-send).
184
+ const ownedChannel = await findOneWithDecryption(
185
+ em,
186
+ CommunicationChannel,
187
+ {
188
+ id: body.channelId,
189
+ tenantId,
190
+ organizationId,
191
+ deletedAt: null,
192
+ },
193
+ undefined,
194
+ { tenantId, organizationId },
195
+ )
196
+ if (!ownedChannel) {
197
+ return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
198
+ }
199
+
200
+ // Tenant/org scope alone does not authorize: `connect_user_channel` is granted
201
+ // broadly, so a same-tenant caller could otherwise seed against a colleague's
202
+ // personal mailbox. Enforce the module's owner-only contract — personal
203
+ // channels are owner-restricted, shared channels need `manage`.
204
+ let userFeatures: string[] = []
205
+ try {
206
+ const rbac = container.resolve('rbacService') as RbacServiceLike
207
+ const acl = await rbac.loadAcl(userId, { tenantId, organizationId })
208
+ userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []
209
+ } catch {
210
+ userFeatures = []
211
+ }
212
+ try {
213
+ assertCanManageChannel(
214
+ { userId: ownedChannel.userId },
215
+ userId,
216
+ userFeatures,
217
+ 'communication_channels.manage',
218
+ )
219
+ } catch (err) {
220
+ if (err instanceof ChannelAccessDeniedError) {
221
+ return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
222
+ }
223
+ const status = (err as { statusCode?: number }).statusCode ?? 403
224
+ return NextResponse.json(
225
+ { error: err instanceof Error ? err.message : 'Access denied' },
226
+ { status },
227
+ )
228
+ }
229
+
167
230
  // A MessageChannelLink requires a non-null external_conversation_id (FK) and
168
231
  // message_id. Create a synthetic conversation + (optionally threaded) message
169
232
  // so the link is shaped like a real inbound row the subscriber can consume.
@@ -283,7 +346,11 @@ export const openApi = {
283
346
  responses: [
284
347
  { status: 201, description: 'Channel seeded / inbound message emitted' },
285
348
  { status: 401, description: 'Unauthorized' },
286
- { status: 404, description: 'Test channel seeding disabled (production default)' },
349
+ {
350
+ status: 404,
351
+ description:
352
+ 'Test channel seeding disabled (production default), or the requested channel does not belong to the caller',
353
+ },
287
354
  { status: 422, description: 'Invalid request body' },
288
355
  { status: 500, description: 'Seed failed' },
289
356
  ],
@@ -4,6 +4,7 @@ import { RateFetchingService } from './services/rateFetchingService'
4
4
  import { ExchangeRateService } from './services/exchangeRateService'
5
5
  import { NBPProvider } from './services/providers/nbp'
6
6
  import { RaiffeisenPolandProvider } from './services/providers/raiffeisen'
7
+ import { BaseCurrencyService } from './services/baseCurrencyService'
7
8
 
8
9
  export function register(container: AppContainer) {
9
10
  container.register({
@@ -26,6 +27,8 @@ export function register(container: AppContainer) {
26
27
  return new ExchangeRateService(em, rateFetchingService)
27
28
  },
28
29
  },
30
+ baseCurrencyService: {
31
+ resolve: (c) => new BaseCurrencyService(c.resolve<EntityManager>('em')),
32
+ },
29
33
  })
30
34
  }
31
-
@@ -0,0 +1,59 @@
1
+ import type { EntityManager } from '@mikro-orm/core'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+
4
+ const logger = createLogger('currencies').child({ component: 'base-currency-service' })
5
+
6
+ export type BaseCurrencyResolution =
7
+ | { status: 'resolved'; code: string }
8
+ | { status: 'missing' }
9
+ | { status: 'ambiguous' }
10
+ | { status: 'unavailable' }
11
+
12
+ export type BaseCurrencyScope = {
13
+ tenantId: string
14
+ organizationIds: string[]
15
+ }
16
+
17
+ export class BaseCurrencyService {
18
+ constructor(private readonly em: EntityManager) {}
19
+
20
+ async resolveBaseCurrency(scope: BaseCurrencyScope): Promise<BaseCurrencyResolution> {
21
+ const organizationIds = [...new Set(scope.organizationIds)]
22
+ if (organizationIds.length === 0) return { status: 'ambiguous' }
23
+
24
+ let rows: Array<Record<string, unknown>>
25
+ try {
26
+ const result = await this.em.getConnection().execute(
27
+ 'SELECT organization_id, code FROM currencies WHERE tenant_id = ? AND organization_id = ANY(?::uuid[]) AND is_base = true AND deleted_at IS NULL',
28
+ [scope.tenantId, `{${organizationIds.join(',')}}`],
29
+ )
30
+ rows = (Array.isArray(result) ? result : []) as Array<Record<string, unknown>>
31
+ } catch (err) {
32
+ logger.warn('Base currency lookup unavailable', { err })
33
+ return { status: 'unavailable' }
34
+ }
35
+
36
+ const expectedOrganizations = new Set(organizationIds)
37
+ const resolvedOrganizations = new Set<string>()
38
+ const codes = new Set<string>()
39
+
40
+ for (const row of rows) {
41
+ const organizationId = typeof row.organization_id === 'string' ? row.organization_id : ''
42
+ const code = typeof row.code === 'string' ? row.code.trim().toUpperCase() : ''
43
+ if (
44
+ !expectedOrganizations.has(organizationId)
45
+ || resolvedOrganizations.has(organizationId)
46
+ || !code
47
+ ) {
48
+ return { status: 'ambiguous' }
49
+ }
50
+ resolvedOrganizations.add(organizationId)
51
+ codes.add(code)
52
+ }
53
+
54
+ if (resolvedOrganizations.size !== expectedOrganizations.size) return { status: 'missing' }
55
+ if (codes.size !== 1) return { status: 'ambiguous' }
56
+ const [code] = codes
57
+ return code ? { status: 'resolved', code } : { status: 'ambiguous' }
58
+ }
59
+ }
@@ -26,10 +26,12 @@ export const analyticsConfig: AnalyticsModuleConfig = {
26
26
  tableName: 'customer_deals',
27
27
  dateField: 'created_at',
28
28
  defaultScopeFields: ['tenant_id', 'organization_id'],
29
+ currencyField: 'valueCurrency',
29
30
  },
30
31
  fieldMappings: {
31
32
  id: { dbColumn: 'id', type: 'uuid' },
32
33
  valueAmount: { dbColumn: 'value_amount', type: 'numeric' },
34
+ valueCurrency: { dbColumn: 'value_currency', type: 'text' },
33
35
  status: { dbColumn: 'status', type: 'text' },
34
36
  closureOutcome: { dbColumn: 'closure_outcome', type: 'text' },
35
37
  pipelineStage: { dbColumn: 'pipeline_stage', type: 'text' },
@@ -6,6 +6,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
7
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
8
8
  import { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'
9
+ import { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'
9
10
  import type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
10
11
  import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
11
12
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
@@ -181,11 +182,11 @@ export async function GET(req: Request) {
181
182
  // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an
182
183
  // aggregate that never reads `title`/`description`. The search path still relies on
183
184
  // the token index above to find matching deals when encrypted columns are involved.
184
- const baseCurrency = await em.getConnection().execute<Array<{ code: string }>>(
185
- `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
186
- [effectiveTenantId, orgFilterIds[0]],
185
+ const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(
186
+ container,
187
+ effectiveTenantId,
188
+ orgFilterIds[0],
187
189
  )
188
- const baseCurrencyCode = baseCurrency[0]?.code ?? null
189
190
 
190
191
  // Build WHERE clause shared between count + sum queries
191
192
  const orgPlaceholders = orgFilterIds.map(() => '?').join(',')
@@ -9,6 +9,7 @@ import type { ExchangeRateService, RateResult } from '@open-mercato/core/modules
9
9
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
10
  import { fetchStuckDealIds } from '../../../lib/stuckDeals'
11
11
  import { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'
12
+ import { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'
12
13
  import {
13
14
  computeDelta,
14
15
  convertSumsToBase,
@@ -186,11 +187,11 @@ export async function GET(req: Request) {
186
187
 
187
188
  const connection = em.getConnection()
188
189
 
189
- const baseCurrency: Array<{ code: string }> = await connection.execute<Array<{ code: string }>>(
190
- `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
191
- [effectiveTenantId, orgFilterIds[0]],
190
+ const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(
191
+ container,
192
+ effectiveTenantId,
193
+ orgFilterIds[0],
192
194
  )
193
- const baseCurrencyCode = baseCurrency[0]?.code ?? null
194
195
 
195
196
  const orgPlaceholders = orgFilterIds.map(() => '?').join(',')
196
197
  const scopeWhere = `tenant_id = ? AND organization_id IN (${orgPlaceholders}) AND deleted_at IS NULL`
@@ -0,0 +1,34 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+
3
+ type BaseCurrencyResolution =
4
+ | { status: 'resolved'; code: string }
5
+ | { status: 'missing' | 'ambiguous' | 'unavailable' }
6
+
7
+ type BaseCurrencyResolver = {
8
+ resolveBaseCurrency(scope: {
9
+ tenantId: string
10
+ organizationIds: string[]
11
+ }): Promise<BaseCurrencyResolution>
12
+ }
13
+
14
+ export async function resolveOptionalBaseCurrencyCode(
15
+ container: AppContainer,
16
+ tenantId: string,
17
+ organizationId: string | undefined,
18
+ ): Promise<string | null> {
19
+ if (!organizationId) return null
20
+
21
+ let resolver: BaseCurrencyResolver
22
+ try {
23
+ resolver = container.resolve<BaseCurrencyResolver>('baseCurrencyService')
24
+ } catch {
25
+ return null
26
+ }
27
+
28
+ try {
29
+ const result = await resolver.resolveBaseCurrency({ tenantId, organizationIds: [organizationId] })
30
+ return result.status === 'resolved' ? result.code : null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
@@ -18,6 +18,7 @@ import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib
18
18
  import { dashboardsTag, dashboardsErrorSchema } from '../../../openapi'
19
19
  import { widgetDataRequestSchema, widgetDataResponseSchema } from '../schema'
20
20
  import { createLogger } from '@open-mercato/shared/lib/logger'
21
+ import { resolveOptionalBaseCurrencyResolver } from '../../../../lib/optionalBaseCurrency'
21
22
 
22
23
  const logger = createLogger('dashboards').child({ component: 'widgets-data-batch' })
23
24
 
@@ -104,7 +105,13 @@ export async function POST(req: Request) {
104
105
  })()
105
106
 
106
107
  const cache = container.resolve<CacheStrategy>('cache')
107
- const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)
108
+ const service = createWidgetDataService(
109
+ em,
110
+ { tenantId, organizationIds },
111
+ analyticsRegistry,
112
+ cache,
113
+ resolveOptionalBaseCurrencyResolver(container),
114
+ )
108
115
 
109
116
  const rbacService = container.resolve<{
110
117
  userHasAllFeatures: (
@@ -17,6 +17,7 @@ import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib
17
17
  import { dashboardsTag, dashboardsErrorSchema } from '../../openapi'
18
18
  import { widgetDataRequestSchema, widgetDataResponseSchema } from './schema'
19
19
  import { createLogger } from '@open-mercato/shared/lib/logger'
20
+ import { resolveOptionalBaseCurrencyResolver } from '../../../lib/optionalBaseCurrency'
20
21
 
21
22
  const logger = createLogger('dashboards').child({ component: 'widgets-data' })
22
23
 
@@ -123,7 +124,13 @@ export async function POST(req: Request) {
123
124
 
124
125
  try {
125
126
  const cache = container.resolve<CacheStrategy>('cache')
126
- const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)
127
+ const service = createWidgetDataService(
128
+ em,
129
+ { tenantId, organizationIds },
130
+ analyticsRegistry,
131
+ cache,
132
+ resolveOptionalBaseCurrencyResolver(container),
133
+ )
127
134
  const result = await service.fetchWidgetData(requestData)
128
135
  return NextResponse.json(result)
129
136
  } catch (err) {
@@ -86,5 +86,6 @@ export const widgetDataResponseSchema = z.object({
86
86
  metadata: z.object({
87
87
  fetchedAt: z.string(),
88
88
  recordCount: z.number(),
89
+ currency: z.string().nullable().optional(),
89
90
  }),
90
91
  })
@@ -0,0 +1,43 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Info } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ export type UnlabelledAmountNoticeProps = {
8
+ currency: string | null | undefined
9
+ loading?: boolean
10
+ error?: string | null
11
+ className?: string
12
+ }
13
+
14
+ /**
15
+ * Explains why a money widget renders bare numbers. Without it a missing currency label
16
+ * is indistinguishable from a broken widget, which is the gap #4676 closes: the amounts
17
+ * are unlabelled on purpose because the service could not determine one reliably.
18
+ */
19
+ export const UnlabelledAmountNotice: React.FC<UnlabelledAmountNoticeProps> = ({
20
+ currency,
21
+ loading,
22
+ error,
23
+ className,
24
+ }) => {
25
+ const t = useT()
26
+
27
+ if (loading || error || currency) return null
28
+
29
+ return (
30
+ <p
31
+ className={`mt-2 flex items-center gap-1 text-xs text-muted-foreground${className ? ` ${className}` : ''}`}
32
+ title={t(
33
+ 'dashboards.analytics.currency.unlabelledHint',
34
+ 'A reliable currency could not be determined, so the amount is shown without one.',
35
+ )}
36
+ >
37
+ <Info className="size-3 shrink-0" aria-hidden="true" />
38
+ {t('dashboards.analytics.currency.unlabelled', 'Amounts shown without a currency')}
39
+ </p>
40
+ )
41
+ }
42
+
43
+ export default UnlabelledAmountNotice
@@ -15,6 +15,8 @@
15
15
  "dashboards.analytics.comparison.vsWeekBefore": "vs Woche davor",
16
16
  "dashboards.analytics.comparison.vsYearBefore": "vs Jahr davor",
17
17
  "dashboards.analytics.comparison.vsYesterday": "vs gestern",
18
+ "dashboards.analytics.currency.unlabelled": "Beträge ohne Währungsangabe",
19
+ "dashboards.analytics.currency.unlabelledHint": "Eine verlässliche Währung konnte nicht ermittelt werden, daher wird der Betrag ohne Währungsangabe angezeigt.",
18
20
  "dashboards.analytics.dateRange.last30Days": "Letzte 30 Tage",
19
21
  "dashboards.analytics.dateRange.last7Days": "Letzte 7 Tage",
20
22
  "dashboards.analytics.dateRange.last90Days": "Letzte 90 Tage",
@@ -15,6 +15,8 @@
15
15
  "dashboards.analytics.comparison.vsWeekBefore": "vs week before",
16
16
  "dashboards.analytics.comparison.vsYearBefore": "vs year before",
17
17
  "dashboards.analytics.comparison.vsYesterday": "vs yesterday",
18
+ "dashboards.analytics.currency.unlabelled": "Amounts shown without a currency",
19
+ "dashboards.analytics.currency.unlabelledHint": "A reliable currency could not be determined, so the amount is shown without one.",
18
20
  "dashboards.analytics.dateRange.last30Days": "Last 30 Days",
19
21
  "dashboards.analytics.dateRange.last7Days": "Last 7 Days",
20
22
  "dashboards.analytics.dateRange.last90Days": "Last 90 Days",
@@ -15,6 +15,8 @@
15
15
  "dashboards.analytics.comparison.vsWeekBefore": "vs semana anterior",
16
16
  "dashboards.analytics.comparison.vsYearBefore": "vs año anterior",
17
17
  "dashboards.analytics.comparison.vsYesterday": "vs ayer",
18
+ "dashboards.analytics.currency.unlabelled": "Importes mostrados sin moneda",
19
+ "dashboards.analytics.currency.unlabelledHint": "No se pudo determinar una moneda fiable, por lo que el importe se muestra sin moneda.",
18
20
  "dashboards.analytics.dateRange.last30Days": "Últimos 30 días",
19
21
  "dashboards.analytics.dateRange.last7Days": "Últimos 7 días",
20
22
  "dashboards.analytics.dateRange.last90Days": "Últimos 90 días",
@@ -15,6 +15,8 @@
15
15
  "dashboards.analytics.comparison.vsWeekBefore": "vs tydzien wczesniej",
16
16
  "dashboards.analytics.comparison.vsYearBefore": "vs rok wczesniej",
17
17
  "dashboards.analytics.comparison.vsYesterday": "vs wczoraj",
18
+ "dashboards.analytics.currency.unlabelled": "Kwoty pokazane bez waluty",
19
+ "dashboards.analytics.currency.unlabelledHint": "Nie udało się wiarygodnie ustalić waluty, dlatego kwota jest wyświetlana bez jej oznaczenia.",
18
20
  "dashboards.analytics.dateRange.last30Days": "Ostatnie 30 dni",
19
21
  "dashboards.analytics.dateRange.last7Days": "Ostatnie 7 dni",
20
22
  "dashboards.analytics.dateRange.last90Days": "Ostatnie 90 dni",
@@ -224,6 +224,41 @@ function resolveTableName(config: EntityTypeConfig): string {
224
224
  return config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`
225
225
  }
226
226
 
227
+ export type BuildDistinctCurrencyQueryOptions = Pick<
228
+ BuildAggregationQueryOptions,
229
+ 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'
230
+ >
231
+
232
+ /**
233
+ * Builds the query that reads the distinct per-row currencies of the rows an aggregation
234
+ * would sum, over exactly the same tenant/organization scope, date range and filters.
235
+ * Returns `null` when the entity declares no `currencyField`, which means its amounts are
236
+ * not per-row denominated and there is nothing to verify (#4676).
237
+ */
238
+ export function buildDistinctCurrencyQuery(
239
+ options: BuildDistinctCurrencyQueryOptions,
240
+ ): AggregationQuery | null {
241
+ const { registry } = options
242
+ const config = registry.getEntityTypeConfig(options.entityType)
243
+ if (!config?.currencyField) return null
244
+
245
+ const currencyMapping = registry.getFieldMapping(options.entityType, config.currencyField)
246
+ if (!currencyMapping || !isSafeIdentifier(currencyMapping.dbColumn)) return null
247
+
248
+ const where = buildWhereClause(options)
249
+ const normalizedCurrencyExpression = `UPPER(NULLIF(BTRIM(${currencyMapping.dbColumn}), ''))`
250
+ const sql = [
251
+ `SELECT DISTINCT ${normalizedCurrencyExpression} AS code`,
252
+ `FROM ${resolveTableName(config)}`,
253
+ where.clause,
254
+ 'LIMIT 2',
255
+ ]
256
+ .filter(Boolean)
257
+ .join(' ')
258
+
259
+ return { sql, params: where.params }
260
+ }
261
+
227
262
  export function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {
228
263
  const { registry } = options
229
264
  const config = registry.getEntityTypeConfig(options.entityType)