@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/api/route.js +16 -2
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/upload-limits.js +33 -0
- package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
- package/dist/modules/currencies/di.js +4 -0
- package/dist/modules/currencies/di.js.map +2 -2
- package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
- package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
- package/dist/modules/customers/analytics.js +3 -1
- package/dist/modules/customers/analytics.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +5 -4
- package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
- package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
- package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
- package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
- package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
- package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
- package/dist/modules/dashboards/lib/aggregations.js +17 -0
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/dashboards/lib/formatters.js +84 -11
- package/dist/modules/dashboards/lib/formatters.js.map +2 -2
- package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
- package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +86 -6
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
- package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
- package/dist/modules/notifications/api/route.js +62 -3
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/sales/analytics.js +7 -3
- package/dist/modules/sales/analytics.js.map +2 -2
- package/dist/modules/sales/api/document-history/route.js +2 -1
- package/dist/modules/sales/api/document-history/route.js.map +2 -2
- package/dist/modules/sales/lib/historyHelpers.js +69 -1
- package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/route.ts +16 -2
- package/src/modules/attachments/lib/upload-limits.ts +35 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
- package/src/modules/currencies/di.ts +4 -1
- package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
- package/src/modules/customers/analytics.ts +2 -0
- package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
- package/src/modules/customers/api/deals/summary/route.ts +5 -4
- package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
- package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
- package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
- package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
- package/src/modules/dashboards/i18n/de.json +2 -0
- package/src/modules/dashboards/i18n/en.json +2 -0
- package/src/modules/dashboards/i18n/es.json +2 -0
- package/src/modules/dashboards/i18n/pl.json +2 -0
- package/src/modules/dashboards/lib/aggregations.ts +35 -0
- package/src/modules/dashboards/lib/formatters.ts +137 -12
- package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
- package/src/modules/dashboards/services/widgetDataService.ts +110 -4
- package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
- package/src/modules/notifications/api/route.ts +88 -3
- package/src/modules/sales/analytics.ts +4 -0
- package/src/modules/sales/api/document-history/route.ts +1 -0
- package/src/modules/sales/i18n/de.json +19 -0
- package/src/modules/sales/i18n/en.json +19 -0
- package/src/modules/sales/i18n/es.json +19 -0
- package/src/modules/sales/i18n/pl.json +19 -0
- package/src/modules/sales/lib/historyHelpers.ts +88 -1
- 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({
|
|
288
|
+
return NextResponse.json({
|
|
289
|
+
error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
|
|
290
|
+
}, { status: 413 })
|
|
287
291
|
}
|
|
288
292
|
|
|
289
|
-
|
|
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 {
|
|
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
|
-
{
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
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(
|
|
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) {
|
|
@@ -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)
|