@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/dashboards/api/widgets/data/schema.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\n\nexport const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])\nexport const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])\nexport const dateRangePresetSchema = z.enum([\n 'today',\n 'yesterday',\n 'this_week',\n 'last_week',\n 'this_month',\n 'last_month',\n 'this_quarter',\n 'last_quarter',\n 'this_year',\n 'last_year',\n 'last_7_days',\n 'last_30_days',\n 'last_90_days',\n])\n\nexport const filterOperatorSchema = z.enum([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'in',\n 'not_in',\n 'is_null',\n 'is_not_null',\n])\n\nexport const widgetDataRequestSchema = z.object({\n entityType: z.string().min(1),\n metric: z.object({\n field: z.string().min(1),\n aggregate: aggregateFunctionSchema,\n }),\n groupBy: z\n .object({\n field: z.string().min(1),\n granularity: dateGranularitySchema.optional(),\n limit: z.number().int().min(1).max(100).optional(),\n resolveLabels: z.boolean().optional(),\n })\n .optional(),\n filters: z\n .array(\n z.object({\n field: z.string().min(1),\n operator: filterOperatorSchema,\n value: z.unknown().optional(),\n }),\n )\n .optional(),\n dateRange: z\n .object({\n field: z.string().min(1),\n preset: dateRangePresetSchema,\n })\n .optional(),\n comparison: z\n .object({\n type: z.enum(['previous_period', 'previous_year']),\n })\n .optional(),\n})\n\nexport const widgetDataItemSchema = z.object({\n groupKey: z.unknown(),\n groupLabel: z.string().optional(),\n value: z.number().nullable(),\n})\n\nexport const widgetDataResponseSchema = z.object({\n value: z.number().nullable(),\n data: z.array(widgetDataItemSchema),\n comparison: z\n .object({\n value: z.number().nullable(),\n change: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n })\n .optional(),\n metadata: z.object({\n fetchedAt: z.string(),\n recordCount: z.number(),\n }),\n})\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,0BAA0B,EAAE,KAAK,CAAC,SAAS,OAAO,OAAO,OAAO,KAAK,CAAC;AAC5E,MAAM,wBAAwB,EAAE,KAAK,CAAC,OAAO,QAAQ,SAAS,WAAW,MAAM,CAAC;AAChF,MAAM,wBAAwB,EAAE,KAAK;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,uBAAuB,EAAE,KAAK;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,0BAA0B,EAAE,OAAO;AAAA,EAC9C,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC5B,QAAQ,EAAE,OAAO;AAAA,IACf,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,WAAW;AAAA,EACb,CAAC;AAAA,EACD,SAAS,EACN,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,aAAa,sBAAsB,SAAS;AAAA,IAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,IACjD,eAAe,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,CAAC,EACA,SAAS;AAAA,EACZ,SAAS,EACN;AAAA,IACC,EAAE,OAAO;AAAA,MACP,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACvB,UAAU;AAAA,MACV,OAAO,EAAE,QAAQ,EAAE,SAAS;AAAA,IAC9B,CAAC;AAAA,EACH,EACC,SAAS;AAAA,EACZ,WAAW,EACR,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,QAAQ;AAAA,EACV,CAAC,EACA,SAAS;AAAA,EACZ,YAAY,EACT,OAAO;AAAA,IACN,MAAM,EAAE,KAAK,CAAC,mBAAmB,eAAe,CAAC;AAAA,EACnD,CAAC,EACA,SAAS;AACd,CAAC;AAEM,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,UAAU,EAAE,QAAQ;AAAA,EACpB,YAAY,EAAE,OAAO,EAAE,SAAS;AAAA,EAChC,OAAO,EAAE,OAAO,EAAE,SAAS;AAC7B,CAAC;AAEM,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,MAAM,EAAE,MAAM,oBAAoB;AAAA,EAClC,YAAY,EACT,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,IAC3B,QAAQ,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,KAAK,CAAC,MAAM,QAAQ,WAAW,CAAC;AAAA,EAC/C,CAAC,EACA,SAAS;AAAA,EACZ,UAAU,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,OAAO;AAAA,IACpB,aAAa,EAAE,OAAO;AAAA,
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\n\nexport const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])\nexport const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])\nexport const dateRangePresetSchema = z.enum([\n 'today',\n 'yesterday',\n 'this_week',\n 'last_week',\n 'this_month',\n 'last_month',\n 'this_quarter',\n 'last_quarter',\n 'this_year',\n 'last_year',\n 'last_7_days',\n 'last_30_days',\n 'last_90_days',\n])\n\nexport const filterOperatorSchema = z.enum([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'in',\n 'not_in',\n 'is_null',\n 'is_not_null',\n])\n\nexport const widgetDataRequestSchema = z.object({\n entityType: z.string().min(1),\n metric: z.object({\n field: z.string().min(1),\n aggregate: aggregateFunctionSchema,\n }),\n groupBy: z\n .object({\n field: z.string().min(1),\n granularity: dateGranularitySchema.optional(),\n limit: z.number().int().min(1).max(100).optional(),\n resolveLabels: z.boolean().optional(),\n })\n .optional(),\n filters: z\n .array(\n z.object({\n field: z.string().min(1),\n operator: filterOperatorSchema,\n value: z.unknown().optional(),\n }),\n )\n .optional(),\n dateRange: z\n .object({\n field: z.string().min(1),\n preset: dateRangePresetSchema,\n })\n .optional(),\n comparison: z\n .object({\n type: z.enum(['previous_period', 'previous_year']),\n })\n .optional(),\n})\n\nexport const widgetDataItemSchema = z.object({\n groupKey: z.unknown(),\n groupLabel: z.string().optional(),\n value: z.number().nullable(),\n})\n\nexport const widgetDataResponseSchema = z.object({\n value: z.number().nullable(),\n data: z.array(widgetDataItemSchema),\n comparison: z\n .object({\n value: z.number().nullable(),\n change: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n })\n .optional(),\n metadata: z.object({\n fetchedAt: z.string(),\n recordCount: z.number(),\n currency: z.string().nullable().optional(),\n }),\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,0BAA0B,EAAE,KAAK,CAAC,SAAS,OAAO,OAAO,OAAO,KAAK,CAAC;AAC5E,MAAM,wBAAwB,EAAE,KAAK,CAAC,OAAO,QAAQ,SAAS,WAAW,MAAM,CAAC;AAChF,MAAM,wBAAwB,EAAE,KAAK;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,uBAAuB,EAAE,KAAK;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,0BAA0B,EAAE,OAAO;AAAA,EAC9C,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC5B,QAAQ,EAAE,OAAO;AAAA,IACf,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,WAAW;AAAA,EACb,CAAC;AAAA,EACD,SAAS,EACN,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,aAAa,sBAAsB,SAAS;AAAA,IAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,IACjD,eAAe,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,CAAC,EACA,SAAS;AAAA,EACZ,SAAS,EACN;AAAA,IACC,EAAE,OAAO;AAAA,MACP,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACvB,UAAU;AAAA,MACV,OAAO,EAAE,QAAQ,EAAE,SAAS;AAAA,IAC9B,CAAC;AAAA,EACH,EACC,SAAS;AAAA,EACZ,WAAW,EACR,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,QAAQ;AAAA,EACV,CAAC,EACA,SAAS;AAAA,EACZ,YAAY,EACT,OAAO;AAAA,IACN,MAAM,EAAE,KAAK,CAAC,mBAAmB,eAAe,CAAC;AAAA,EACnD,CAAC,EACA,SAAS;AACd,CAAC;AAEM,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,UAAU,EAAE,QAAQ;AAAA,EACpB,YAAY,EAAE,OAAO,EAAE,SAAS;AAAA,EAChC,OAAO,EAAE,OAAO,EAAE,SAAS;AAC7B,CAAC;AAEM,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,MAAM,EAAE,MAAM,oBAAoB;AAAA,EAClC,YAAY,EACT,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,IAC3B,QAAQ,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,KAAK,CAAC,MAAM,QAAQ,WAAW,CAAC;AAAA,EAC/C,CAAC,EACA,SAAS;AAAA,EACZ,UAAU,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,OAAO;AAAA,IACpB,aAAa,EAAE,OAAO;AAAA,IACtB,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,CAAC;AACH,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Info } from "lucide-react";
|
|
4
|
+
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
5
|
+
const UnlabelledAmountNotice = ({
|
|
6
|
+
currency,
|
|
7
|
+
loading,
|
|
8
|
+
error,
|
|
9
|
+
className
|
|
10
|
+
}) => {
|
|
11
|
+
const t = useT();
|
|
12
|
+
if (loading || error || currency) return null;
|
|
13
|
+
return /* @__PURE__ */ jsxs(
|
|
14
|
+
"p",
|
|
15
|
+
{
|
|
16
|
+
className: `mt-2 flex items-center gap-1 text-xs text-muted-foreground${className ? ` ${className}` : ""}`,
|
|
17
|
+
title: t(
|
|
18
|
+
"dashboards.analytics.currency.unlabelledHint",
|
|
19
|
+
"A reliable currency could not be determined, so the amount is shown without one."
|
|
20
|
+
),
|
|
21
|
+
children: [
|
|
22
|
+
/* @__PURE__ */ jsx(Info, { className: "size-3 shrink-0", "aria-hidden": "true" }),
|
|
23
|
+
t("dashboards.analytics.currency.unlabelled", "Amounts shown without a currency")
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
var UnlabelledAmountNotice_default = UnlabelledAmountNotice;
|
|
29
|
+
export {
|
|
30
|
+
UnlabelledAmountNotice,
|
|
31
|
+
UnlabelledAmountNotice_default as default
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=UnlabelledAmountNotice.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/dashboards/components/UnlabelledAmountNotice.tsx"],
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Info } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type UnlabelledAmountNoticeProps = {\n currency: string | null | undefined\n loading?: boolean\n error?: string | null\n className?: string\n}\n\n/**\n * Explains why a money widget renders bare numbers. Without it a missing currency label\n * is indistinguishable from a broken widget, which is the gap #4676 closes: the amounts\n * are unlabelled on purpose because the service could not determine one reliably.\n */\nexport const UnlabelledAmountNotice: React.FC<UnlabelledAmountNoticeProps> = ({\n currency,\n loading,\n error,\n className,\n}) => {\n const t = useT()\n\n if (loading || error || currency) return null\n\n return (\n <p\n className={`mt-2 flex items-center gap-1 text-xs text-muted-foreground${className ? ` ${className}` : ''}`}\n title={t(\n 'dashboards.analytics.currency.unlabelledHint',\n 'A reliable currency could not be determined, so the amount is shown without one.',\n )}\n >\n <Info className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n {t('dashboards.analytics.currency.unlabelled', 'Amounts shown without a currency')}\n </p>\n )\n}\n\nexport default UnlabelledAmountNotice\n"],
|
|
5
|
+
"mappings": ";AA6BI,SAOE,KAPF;AA1BJ,SAAS,YAAY;AACrB,SAAS,YAAY;AAcd,MAAM,yBAAgE,CAAC;AAAA,EAC5E;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,IAAI,KAAK;AAEf,MAAI,WAAW,SAAS,SAAU,QAAO;AAEzC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,6DAA6D,YAAY,IAAI,SAAS,KAAK,EAAE;AAAA,MACxG,OAAO;AAAA,QACL;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,QAAK,WAAU,mBAAkB,eAAY,QAAO;AAAA,QACpD,EAAE,4CAA4C,kCAAkC;AAAA;AAAA;AAAA,EACnF;AAEJ;AAEA,IAAO,iCAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -140,6 +140,22 @@ function buildWhereClause(options) {
|
|
|
140
140
|
function resolveTableName(config) {
|
|
141
141
|
return config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`;
|
|
142
142
|
}
|
|
143
|
+
function buildDistinctCurrencyQuery(options) {
|
|
144
|
+
const { registry } = options;
|
|
145
|
+
const config = registry.getEntityTypeConfig(options.entityType);
|
|
146
|
+
if (!config?.currencyField) return null;
|
|
147
|
+
const currencyMapping = registry.getFieldMapping(options.entityType, config.currencyField);
|
|
148
|
+
if (!currencyMapping || !isSafeIdentifier(currencyMapping.dbColumn)) return null;
|
|
149
|
+
const where = buildWhereClause(options);
|
|
150
|
+
const normalizedCurrencyExpression = `UPPER(NULLIF(BTRIM(${currencyMapping.dbColumn}), ''))`;
|
|
151
|
+
const sql = [
|
|
152
|
+
`SELECT DISTINCT ${normalizedCurrencyExpression} AS code`,
|
|
153
|
+
`FROM ${resolveTableName(config)}`,
|
|
154
|
+
where.clause,
|
|
155
|
+
"LIMIT 2"
|
|
156
|
+
].filter(Boolean).join(" ");
|
|
157
|
+
return { sql, params: where.params };
|
|
158
|
+
}
|
|
143
159
|
function buildAggregationQuery(options) {
|
|
144
160
|
const { registry } = options;
|
|
145
161
|
const config = registry.getEntityTypeConfig(options.entityType);
|
|
@@ -189,6 +205,7 @@ export {
|
|
|
189
205
|
buildAggregateExpression,
|
|
190
206
|
buildAggregationQuery,
|
|
191
207
|
buildDateTruncExpression,
|
|
208
|
+
buildDistinctCurrencyQuery,
|
|
192
209
|
buildGroupSourceRowsQuery,
|
|
193
210
|
buildJsonbFieldExpression,
|
|
194
211
|
isValidAggregate,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/dashboards/lib/aggregations.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AnalyticsRegistry } from '../services/analyticsRegistry'\nimport type { AnalyticsEntityTypeConfig, AnalyticsFieldMapping } from '@open-mercato/shared/modules/analytics'\n\nexport type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'\nexport type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'\n\nconst VALID_GRANULARITIES: readonly DateGranularity[] = ['day', 'week', 'month', 'quarter', 'year']\nconst VALID_AGGREGATES: readonly AggregateFunction[] = ['count', 'sum', 'avg', 'min', 'max']\nconst SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/\n\nexport function isValidGranularity(value: unknown): value is DateGranularity {\n return typeof value === 'string' && VALID_GRANULARITIES.includes(value as DateGranularity)\n}\n\nexport function isValidAggregate(value: unknown): value is AggregateFunction {\n return typeof value === 'string' && VALID_AGGREGATES.includes(value as AggregateFunction)\n}\n\nfunction isSafeIdentifier(value: string): boolean {\n return SAFE_IDENTIFIER_PATTERN.test(value)\n}\n\n// Re-export types from shared module for convenience\nexport type EntityTypeConfig = AnalyticsEntityTypeConfig\nexport type FieldMapping = AnalyticsFieldMapping\n\nexport function buildAggregateExpression(aggregate: AggregateFunction, column: string): string {\n switch (aggregate) {\n case 'count':\n return column === 'id' ? 'COUNT(*)' : `COUNT(${column})`\n case 'sum':\n return `COALESCE(SUM(${column}::numeric), 0)`\n case 'avg':\n return `COALESCE(AVG(${column}::numeric), 0)`\n case 'min':\n return `MIN(${column}::numeric)`\n case 'max':\n return `MAX(${column}::numeric)`\n default:\n return `COUNT(*)`\n }\n}\n\nexport function buildDateTruncExpression(column: string, granularity: DateGranularity): string {\n if (!isValidGranularity(granularity)) {\n throw new Error(`Invalid granularity: ${granularity}`)\n }\n return `DATE_TRUNC('${granularity}', ${column})`\n}\n\nexport function buildJsonbFieldExpression(column: string, path: string): string {\n const parts = path.split('.')\n for (const part of parts) {\n if (!isSafeIdentifier(part)) {\n throw new Error(`Invalid JSONB path part: ${part}`)\n }\n }\n if (parts.length === 1) {\n return `${column}->>'${parts[0]}'`\n }\n const intermediate = parts.slice(0, -1).map((p) => `'${p}'`).join('->')\n const lastPart = parts[parts.length - 1]\n return `${column}->${intermediate}->>'${lastPart}'`\n}\n\nexport type AggregationQuery = {\n sql: string\n params: unknown[]\n}\n\nexport type BuildAggregationQueryOptions = {\n entityType: string\n metric: {\n field: string\n aggregate: AggregateFunction\n }\n groupBy?: {\n field: string\n granularity?: DateGranularity\n limit?: number\n }\n dateRange?: {\n field: string\n start: Date\n end: Date\n }\n filters?: Array<{\n field: string\n operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'is_null' | 'is_not_null'\n value?: unknown\n }>\n scope: {\n tenantId: string\n organizationIds?: string[]\n }\n /** Analytics registry for resolving entity and field configurations */\n registry: AnalyticsRegistry\n}\n\nexport type ResolvedGroupExpression = {\n expression: string\n /** Column the group expression reads from \u2014 the encryption map is keyed by this. */\n dbColumn: string\n /** JSONB path below `dbColumn`, when the groupBy field used path notation. */\n jsonPath: string | null\n}\n\n/**\n * Resolves the SQL expression a groupBy field maps to, together with the underlying column so\n * callers can decide whether the source is encrypted at rest before grouping over it (#4622).\n */\nexport function resolveGroupExpression(\n registry: AnalyticsRegistry,\n entityType: string,\n groupBy: { field: string; granularity?: DateGranularity },\n): ResolvedGroupExpression | null {\n const groupMapping = registry.getFieldMapping(entityType, groupBy.field)\n\n // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)\n if (!groupMapping && groupBy.field.includes('.')) {\n const [baseField, ...pathParts] = groupBy.field.split('.')\n const baseMapping = registry.getFieldMapping(entityType, baseField)\n if (baseMapping?.type !== 'jsonb') return null\n const jsonPath = pathParts.join('.')\n return {\n expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),\n dbColumn: baseMapping.dbColumn,\n jsonPath,\n }\n }\n\n if (!groupMapping) return null\n\n if (groupMapping.type === 'timestamp' && groupBy.granularity) {\n return {\n expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),\n dbColumn: groupMapping.dbColumn,\n jsonPath: null,\n }\n }\n\n return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null }\n}\n\nfunction buildWhereClause(\n options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,\n): { clause: string; params: unknown[] } {\n const { registry } = options\n const params: unknown[] = []\n const whereClauses: string[] = []\n\n whereClauses.push(`tenant_id = ?`)\n params.push(options.scope.tenantId)\n\n if (options.scope.organizationIds && options.scope.organizationIds.length > 0) {\n whereClauses.push(`organization_id = ANY(?::uuid[])`)\n params.push(`{${options.scope.organizationIds.join(',')}}`)\n }\n\n whereClauses.push(`deleted_at IS NULL`)\n\n if (options.dateRange) {\n const dateMapping = registry.getFieldMapping(options.entityType, options.dateRange.field)\n if (dateMapping) {\n whereClauses.push(`${dateMapping.dbColumn} >= ?`)\n params.push(options.dateRange.start)\n whereClauses.push(`${dateMapping.dbColumn} <= ?`)\n params.push(options.dateRange.end)\n }\n }\n\n if (options.filters) {\n for (const filter of options.filters) {\n const filterMapping = registry.getFieldMapping(options.entityType, filter.field)\n if (!filterMapping) continue\n\n switch (filter.operator) {\n case 'eq':\n whereClauses.push(`${filterMapping.dbColumn} = ?`)\n params.push(filter.value)\n break\n case 'neq':\n whereClauses.push(`${filterMapping.dbColumn} != ?`)\n params.push(filter.value)\n break\n case 'gt':\n whereClauses.push(`${filterMapping.dbColumn} > ?`)\n params.push(filter.value)\n break\n case 'gte':\n whereClauses.push(`${filterMapping.dbColumn} >= ?`)\n params.push(filter.value)\n break\n case 'lt':\n whereClauses.push(`${filterMapping.dbColumn} < ?`)\n params.push(filter.value)\n break\n case 'lte':\n whereClauses.push(`${filterMapping.dbColumn} <= ?`)\n params.push(filter.value)\n break\n case 'in':\n whereClauses.push(`${filterMapping.dbColumn} = ANY(?)`)\n params.push(filter.value)\n break\n case 'not_in':\n whereClauses.push(`${filterMapping.dbColumn} != ALL(?)`)\n params.push(filter.value)\n break\n case 'is_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NULL`)\n break\n case 'is_not_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NOT NULL`)\n break\n }\n }\n }\n\n return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '', params }\n}\n\nfunction resolveTableName(config: EntityTypeConfig): string {\n return config.schema ? `\"${config.schema}\".\"${config.tableName}\"` : `\"${config.tableName}\"`\n}\n\nexport function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n const tableName = resolveTableName(config)\n const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)\n\n let selectClause = `SELECT ${aggregateExpr} AS value`\n let groupByClause = ''\n let orderByClause = ''\n let limitClause = ''\n\n if (options.groupBy) {\n const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy)\n\n if (resolved) {\n selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`\n groupByClause = `GROUP BY ${resolved.expression}`\n // NULLS LAST is stated explicitly (PostgreSQL defaults DESC to NULLS FIRST) so a group limit\n // keeps the highest-value buckets instead of the empty ones, and so the application-side\n // encrypted path can mirror the same ordering (#4622).\n orderByClause = `ORDER BY value DESC NULLS LAST`\n\n if (options.groupBy.limit && options.groupBy.limit > 0) {\n limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`\n }\n }\n }\n\n const where = buildWhereClause(options)\n\n const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n\nexport type BuildGroupSourceRowsQueryOptions = Omit<BuildAggregationQueryOptions, 'groupBy'> & {\n /** Column holding the (encrypted) group source, resolved via `resolveGroupExpression`. */\n groupColumn: string\n /** Hard cap on scanned rows; callers fetch `rowLimit + 1` to detect overflow. */\n rowLimit: number\n}\n\n/**\n * Builds a per-row query for group sources that cannot be grouped in SQL because the column is\n * encrypted at rest. Returns the raw group source alongside the metric value so the caller can\n * decrypt, then aggregate in application code (#4622).\n */\nexport function buildGroupSourceRowsQuery(options: BuildGroupSourceRowsQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n if (!isSafeIdentifier(options.groupColumn)) {\n throw new Error(`Invalid group column: ${options.groupColumn}`)\n }\n\n const where = buildWhereClause(options)\n const sql = [\n `SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,\n `FROM ${resolveTableName(config)}`,\n where.clause,\n `LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`,\n ]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import type { AnalyticsRegistry } from '../services/analyticsRegistry'\nimport type { AnalyticsEntityTypeConfig, AnalyticsFieldMapping } from '@open-mercato/shared/modules/analytics'\n\nexport type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'\nexport type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'\n\nconst VALID_GRANULARITIES: readonly DateGranularity[] = ['day', 'week', 'month', 'quarter', 'year']\nconst VALID_AGGREGATES: readonly AggregateFunction[] = ['count', 'sum', 'avg', 'min', 'max']\nconst SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/\n\nexport function isValidGranularity(value: unknown): value is DateGranularity {\n return typeof value === 'string' && VALID_GRANULARITIES.includes(value as DateGranularity)\n}\n\nexport function isValidAggregate(value: unknown): value is AggregateFunction {\n return typeof value === 'string' && VALID_AGGREGATES.includes(value as AggregateFunction)\n}\n\nfunction isSafeIdentifier(value: string): boolean {\n return SAFE_IDENTIFIER_PATTERN.test(value)\n}\n\n// Re-export types from shared module for convenience\nexport type EntityTypeConfig = AnalyticsEntityTypeConfig\nexport type FieldMapping = AnalyticsFieldMapping\n\nexport function buildAggregateExpression(aggregate: AggregateFunction, column: string): string {\n switch (aggregate) {\n case 'count':\n return column === 'id' ? 'COUNT(*)' : `COUNT(${column})`\n case 'sum':\n return `COALESCE(SUM(${column}::numeric), 0)`\n case 'avg':\n return `COALESCE(AVG(${column}::numeric), 0)`\n case 'min':\n return `MIN(${column}::numeric)`\n case 'max':\n return `MAX(${column}::numeric)`\n default:\n return `COUNT(*)`\n }\n}\n\nexport function buildDateTruncExpression(column: string, granularity: DateGranularity): string {\n if (!isValidGranularity(granularity)) {\n throw new Error(`Invalid granularity: ${granularity}`)\n }\n return `DATE_TRUNC('${granularity}', ${column})`\n}\n\nexport function buildJsonbFieldExpression(column: string, path: string): string {\n const parts = path.split('.')\n for (const part of parts) {\n if (!isSafeIdentifier(part)) {\n throw new Error(`Invalid JSONB path part: ${part}`)\n }\n }\n if (parts.length === 1) {\n return `${column}->>'${parts[0]}'`\n }\n const intermediate = parts.slice(0, -1).map((p) => `'${p}'`).join('->')\n const lastPart = parts[parts.length - 1]\n return `${column}->${intermediate}->>'${lastPart}'`\n}\n\nexport type AggregationQuery = {\n sql: string\n params: unknown[]\n}\n\nexport type BuildAggregationQueryOptions = {\n entityType: string\n metric: {\n field: string\n aggregate: AggregateFunction\n }\n groupBy?: {\n field: string\n granularity?: DateGranularity\n limit?: number\n }\n dateRange?: {\n field: string\n start: Date\n end: Date\n }\n filters?: Array<{\n field: string\n operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'is_null' | 'is_not_null'\n value?: unknown\n }>\n scope: {\n tenantId: string\n organizationIds?: string[]\n }\n /** Analytics registry for resolving entity and field configurations */\n registry: AnalyticsRegistry\n}\n\nexport type ResolvedGroupExpression = {\n expression: string\n /** Column the group expression reads from \u2014 the encryption map is keyed by this. */\n dbColumn: string\n /** JSONB path below `dbColumn`, when the groupBy field used path notation. */\n jsonPath: string | null\n}\n\n/**\n * Resolves the SQL expression a groupBy field maps to, together with the underlying column so\n * callers can decide whether the source is encrypted at rest before grouping over it (#4622).\n */\nexport function resolveGroupExpression(\n registry: AnalyticsRegistry,\n entityType: string,\n groupBy: { field: string; granularity?: DateGranularity },\n): ResolvedGroupExpression | null {\n const groupMapping = registry.getFieldMapping(entityType, groupBy.field)\n\n // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)\n if (!groupMapping && groupBy.field.includes('.')) {\n const [baseField, ...pathParts] = groupBy.field.split('.')\n const baseMapping = registry.getFieldMapping(entityType, baseField)\n if (baseMapping?.type !== 'jsonb') return null\n const jsonPath = pathParts.join('.')\n return {\n expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),\n dbColumn: baseMapping.dbColumn,\n jsonPath,\n }\n }\n\n if (!groupMapping) return null\n\n if (groupMapping.type === 'timestamp' && groupBy.granularity) {\n return {\n expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),\n dbColumn: groupMapping.dbColumn,\n jsonPath: null,\n }\n }\n\n return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null }\n}\n\nfunction buildWhereClause(\n options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,\n): { clause: string; params: unknown[] } {\n const { registry } = options\n const params: unknown[] = []\n const whereClauses: string[] = []\n\n whereClauses.push(`tenant_id = ?`)\n params.push(options.scope.tenantId)\n\n if (options.scope.organizationIds && options.scope.organizationIds.length > 0) {\n whereClauses.push(`organization_id = ANY(?::uuid[])`)\n params.push(`{${options.scope.organizationIds.join(',')}}`)\n }\n\n whereClauses.push(`deleted_at IS NULL`)\n\n if (options.dateRange) {\n const dateMapping = registry.getFieldMapping(options.entityType, options.dateRange.field)\n if (dateMapping) {\n whereClauses.push(`${dateMapping.dbColumn} >= ?`)\n params.push(options.dateRange.start)\n whereClauses.push(`${dateMapping.dbColumn} <= ?`)\n params.push(options.dateRange.end)\n }\n }\n\n if (options.filters) {\n for (const filter of options.filters) {\n const filterMapping = registry.getFieldMapping(options.entityType, filter.field)\n if (!filterMapping) continue\n\n switch (filter.operator) {\n case 'eq':\n whereClauses.push(`${filterMapping.dbColumn} = ?`)\n params.push(filter.value)\n break\n case 'neq':\n whereClauses.push(`${filterMapping.dbColumn} != ?`)\n params.push(filter.value)\n break\n case 'gt':\n whereClauses.push(`${filterMapping.dbColumn} > ?`)\n params.push(filter.value)\n break\n case 'gte':\n whereClauses.push(`${filterMapping.dbColumn} >= ?`)\n params.push(filter.value)\n break\n case 'lt':\n whereClauses.push(`${filterMapping.dbColumn} < ?`)\n params.push(filter.value)\n break\n case 'lte':\n whereClauses.push(`${filterMapping.dbColumn} <= ?`)\n params.push(filter.value)\n break\n case 'in':\n whereClauses.push(`${filterMapping.dbColumn} = ANY(?)`)\n params.push(filter.value)\n break\n case 'not_in':\n whereClauses.push(`${filterMapping.dbColumn} != ALL(?)`)\n params.push(filter.value)\n break\n case 'is_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NULL`)\n break\n case 'is_not_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NOT NULL`)\n break\n }\n }\n }\n\n return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '', params }\n}\n\nfunction resolveTableName(config: EntityTypeConfig): string {\n return config.schema ? `\"${config.schema}\".\"${config.tableName}\"` : `\"${config.tableName}\"`\n}\n\nexport type BuildDistinctCurrencyQueryOptions = Pick<\n BuildAggregationQueryOptions,\n 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'\n>\n\n/**\n * Builds the query that reads the distinct per-row currencies of the rows an aggregation\n * would sum, over exactly the same tenant/organization scope, date range and filters.\n * Returns `null` when the entity declares no `currencyField`, which means its amounts are\n * not per-row denominated and there is nothing to verify (#4676).\n */\nexport function buildDistinctCurrencyQuery(\n options: BuildDistinctCurrencyQueryOptions,\n): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config?.currencyField) return null\n\n const currencyMapping = registry.getFieldMapping(options.entityType, config.currencyField)\n if (!currencyMapping || !isSafeIdentifier(currencyMapping.dbColumn)) return null\n\n const where = buildWhereClause(options)\n const normalizedCurrencyExpression = `UPPER(NULLIF(BTRIM(${currencyMapping.dbColumn}), ''))`\n const sql = [\n `SELECT DISTINCT ${normalizedCurrencyExpression} AS code`,\n `FROM ${resolveTableName(config)}`,\n where.clause,\n 'LIMIT 2',\n ]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n\nexport function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n const tableName = resolveTableName(config)\n const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)\n\n let selectClause = `SELECT ${aggregateExpr} AS value`\n let groupByClause = ''\n let orderByClause = ''\n let limitClause = ''\n\n if (options.groupBy) {\n const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy)\n\n if (resolved) {\n selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`\n groupByClause = `GROUP BY ${resolved.expression}`\n // NULLS LAST is stated explicitly (PostgreSQL defaults DESC to NULLS FIRST) so a group limit\n // keeps the highest-value buckets instead of the empty ones, and so the application-side\n // encrypted path can mirror the same ordering (#4622).\n orderByClause = `ORDER BY value DESC NULLS LAST`\n\n if (options.groupBy.limit && options.groupBy.limit > 0) {\n limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`\n }\n }\n }\n\n const where = buildWhereClause(options)\n\n const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n\nexport type BuildGroupSourceRowsQueryOptions = Omit<BuildAggregationQueryOptions, 'groupBy'> & {\n /** Column holding the (encrypted) group source, resolved via `resolveGroupExpression`. */\n groupColumn: string\n /** Hard cap on scanned rows; callers fetch `rowLimit + 1` to detect overflow. */\n rowLimit: number\n}\n\n/**\n * Builds a per-row query for group sources that cannot be grouped in SQL because the column is\n * encrypted at rest. Returns the raw group source alongside the metric value so the caller can\n * decrypt, then aggregate in application code (#4622).\n */\nexport function buildGroupSourceRowsQuery(options: BuildGroupSourceRowsQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n if (!isSafeIdentifier(options.groupColumn)) {\n throw new Error(`Invalid group column: ${options.groupColumn}`)\n }\n\n const where = buildWhereClause(options)\n const sql = [\n `SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,\n `FROM ${resolveTableName(config)}`,\n where.clause,\n `LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`,\n ]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,31 +1,104 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
|
|
2
|
+
const NUMBER_FORMATTER_CACHE_LIMIT = 128;
|
|
3
|
+
const numberFormatterCache = /* @__PURE__ */ new Map();
|
|
4
|
+
function normalizeCurrencyCode(currency) {
|
|
5
|
+
if (typeof currency !== "string") return null;
|
|
6
|
+
const trimmed = currency.trim();
|
|
7
|
+
if (!CURRENCY_CODE_PATTERN.test(trimmed)) return null;
|
|
8
|
+
return trimmed.toUpperCase();
|
|
9
|
+
}
|
|
10
|
+
function getNumberFormatter(locale, options) {
|
|
11
|
+
const key = `${locale ?? ""}:${JSON.stringify(options)}`;
|
|
12
|
+
const cached = numberFormatterCache.get(key);
|
|
13
|
+
if (cached) return cached;
|
|
14
|
+
const formatter = new Intl.NumberFormat(locale, options);
|
|
15
|
+
if (numberFormatterCache.size >= NUMBER_FORMATTER_CACHE_LIMIT) {
|
|
16
|
+
const oldest = numberFormatterCache.keys().next().value;
|
|
17
|
+
if (oldest) numberFormatterCache.delete(oldest);
|
|
18
|
+
}
|
|
19
|
+
numberFormatterCache.set(key, formatter);
|
|
20
|
+
return formatter;
|
|
21
|
+
}
|
|
22
|
+
function formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale) {
|
|
23
|
+
return getNumberFormatter(locale, {
|
|
24
|
+
style: "decimal",
|
|
6
25
|
minimumFractionDigits,
|
|
7
26
|
maximumFractionDigits
|
|
8
27
|
}).format(value);
|
|
9
28
|
}
|
|
29
|
+
function formatCurrency(value, options = {}) {
|
|
30
|
+
const { currency, locale, minimumFractionDigits = 0, maximumFractionDigits = 0 } = options;
|
|
31
|
+
const code = normalizeCurrencyCode(currency);
|
|
32
|
+
if (!code) return formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale);
|
|
33
|
+
try {
|
|
34
|
+
return getNumberFormatter(locale, {
|
|
35
|
+
style: "currency",
|
|
36
|
+
currency: code,
|
|
37
|
+
minimumFractionDigits,
|
|
38
|
+
maximumFractionDigits
|
|
39
|
+
}).format(value);
|
|
40
|
+
} catch {
|
|
41
|
+
return `${formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)} ${code}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
10
44
|
function formatCurrencyWithDecimals(value, options = {}) {
|
|
11
45
|
return formatCurrency(value, { minimumFractionDigits: 2, maximumFractionDigits: 2, ...options });
|
|
12
46
|
}
|
|
13
|
-
function
|
|
47
|
+
function formatWithLiteralSymbol(value, symbol) {
|
|
14
48
|
if (Math.abs(value) >= 1e6) {
|
|
15
|
-
return `${
|
|
49
|
+
return `${symbol}${(value / 1e6).toFixed(1)}M`;
|
|
16
50
|
}
|
|
17
51
|
if (Math.abs(value) >= 1e3) {
|
|
18
|
-
return `${
|
|
52
|
+
return `${symbol}${(value / 1e3).toFixed(1)}K`;
|
|
19
53
|
}
|
|
20
|
-
return `${
|
|
54
|
+
return `${symbol}${value.toFixed(0)}`;
|
|
21
55
|
}
|
|
22
|
-
function
|
|
56
|
+
function formatCurrencyCompact(value, currencyOrOptions = {}) {
|
|
57
|
+
if (typeof currencyOrOptions === "string" && !normalizeCurrencyCode(currencyOrOptions)) {
|
|
58
|
+
return formatWithLiteralSymbol(value, currencyOrOptions);
|
|
59
|
+
}
|
|
60
|
+
const options = typeof currencyOrOptions === "string" ? { currency: currencyOrOptions } : currencyOrOptions ?? {};
|
|
61
|
+
const code = normalizeCurrencyCode(options.currency);
|
|
62
|
+
const compact = Math.abs(value) >= 1e3;
|
|
63
|
+
const formatOptions = {
|
|
64
|
+
style: code ? "currency" : "decimal",
|
|
65
|
+
notation: compact ? "compact" : "standard",
|
|
66
|
+
compactDisplay: "short",
|
|
67
|
+
minimumFractionDigits: compact ? 1 : 0,
|
|
68
|
+
maximumFractionDigits: compact ? 1 : 0
|
|
69
|
+
};
|
|
70
|
+
if (code) formatOptions.currency = code;
|
|
71
|
+
try {
|
|
72
|
+
return getNumberFormatter(options.locale, formatOptions).format(value);
|
|
73
|
+
} catch {
|
|
74
|
+
const formatted = getNumberFormatter(options.locale, {
|
|
75
|
+
style: "decimal",
|
|
76
|
+
notation: compact ? "compact" : "standard",
|
|
77
|
+
compactDisplay: "short",
|
|
78
|
+
minimumFractionDigits: compact ? 1 : 0,
|
|
79
|
+
maximumFractionDigits: compact ? 1 : 0
|
|
80
|
+
}).format(value);
|
|
81
|
+
return code ? `${formatted} ${code}` : formatted;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function formatCurrencySafe(value, fallback = "--", options = {}) {
|
|
23
85
|
if (value === null || value === void 0) return fallback;
|
|
24
86
|
const num = Number(value);
|
|
25
87
|
if (!Number.isFinite(num)) return fallback;
|
|
26
|
-
return formatCurrency(num);
|
|
88
|
+
return formatCurrency(num, options);
|
|
89
|
+
}
|
|
90
|
+
function createCurrencyFormatters(currency, fallback = "--", locale) {
|
|
91
|
+
const code = normalizeCurrencyCode(currency);
|
|
92
|
+
return {
|
|
93
|
+
currency: code,
|
|
94
|
+
format: (value) => formatCurrency(value, { currency: code, locale }),
|
|
95
|
+
formatWithDecimals: (value) => formatCurrencyWithDecimals(value, { currency: code, locale }),
|
|
96
|
+
formatCompact: (value) => formatCurrencyCompact(value, { currency: code, locale }),
|
|
97
|
+
formatSafe: (value) => formatCurrencySafe(value, fallback, { currency: code, locale })
|
|
98
|
+
};
|
|
27
99
|
}
|
|
28
100
|
export {
|
|
101
|
+
createCurrencyFormatters,
|
|
29
102
|
formatCurrency,
|
|
30
103
|
formatCurrencyCompact,
|
|
31
104
|
formatCurrencySafe,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/dashboards/lib/formatters.ts"],
|
|
4
|
-
"sourcesContent": ["export type FormatCurrencyOptions = {\n currency?: string\n minimumFractionDigits?: number\n maximumFractionDigits?: number\n}\n\nexport function formatCurrency(value: number, options: FormatCurrencyOptions = {}): string {\n const { currency
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["export type FormatCurrencyOptions = {\n currency?: string | null\n locale?: string\n minimumFractionDigits?: number\n maximumFractionDigits?: number\n}\n\nexport type FormatCurrencyCompactOptions = {\n currency?: string | null\n locale?: string\n}\n\nconst CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/\nconst NUMBER_FORMATTER_CACHE_LIMIT = 128\nconst numberFormatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction normalizeCurrencyCode(currency?: string | null): string | null {\n if (typeof currency !== 'string') return null\n const trimmed = currency.trim()\n if (!CURRENCY_CODE_PATTERN.test(trimmed)) return null\n return trimmed.toUpperCase()\n}\n\nfunction getNumberFormatter(\n locale: string | undefined,\n options: Intl.NumberFormatOptions,\n): Intl.NumberFormat {\n const key = `${locale ?? ''}:${JSON.stringify(options)}`\n const cached = numberFormatterCache.get(key)\n if (cached) return cached\n const formatter = new Intl.NumberFormat(locale, options)\n if (numberFormatterCache.size >= NUMBER_FORMATTER_CACHE_LIMIT) {\n const oldest = numberFormatterCache.keys().next().value\n if (oldest) numberFormatterCache.delete(oldest)\n }\n numberFormatterCache.set(key, formatter)\n return formatter\n}\n\nfunction formatDecimal(\n value: number,\n minimumFractionDigits: number,\n maximumFractionDigits: number,\n locale?: string,\n): string {\n return getNumberFormatter(locale, {\n style: 'decimal',\n minimumFractionDigits,\n maximumFractionDigits,\n }).format(value)\n}\n\n/**\n * Formats a monetary amount. Without a currency code the amount renders as a plain\n * number: analytics values carry no currency of their own, and labelling them with a\n * guessed one (see #4620) is worse than leaving them unlabelled.\n */\nexport function formatCurrency(value: number, options: FormatCurrencyOptions = {}): string {\n const { currency, locale, minimumFractionDigits = 0, maximumFractionDigits = 0 } = options\n const code = normalizeCurrencyCode(currency)\n if (!code) return formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)\n try {\n return getNumberFormatter(locale, {\n style: 'currency',\n currency: code,\n minimumFractionDigits,\n maximumFractionDigits,\n }).format(value)\n } catch {\n return `${formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)} ${code}`\n }\n}\n\nexport function formatCurrencyWithDecimals(value: number, options: FormatCurrencyOptions = {}): string {\n return formatCurrency(value, { minimumFractionDigits: 2, maximumFractionDigits: 2, ...options })\n}\n\nfunction formatWithLiteralSymbol(value: number, symbol: string): string {\n if (Math.abs(value) >= 1_000_000) {\n return `${symbol}${(value / 1_000_000).toFixed(1)}M`\n }\n if (Math.abs(value) >= 1_000) {\n return `${symbol}${(value / 1_000).toFixed(1)}K`\n }\n return `${symbol}${value.toFixed(0)}`\n}\n\nexport function formatCurrencyCompact(\n value: number,\n currencyOrOptions: string | null | FormatCurrencyCompactOptions = {},\n): string {\n if (typeof currencyOrOptions === 'string' && !normalizeCurrencyCode(currencyOrOptions)) {\n return formatWithLiteralSymbol(value, currencyOrOptions)\n }\n\n const options = typeof currencyOrOptions === 'string'\n ? { currency: currencyOrOptions }\n : (currencyOrOptions ?? {})\n const code = normalizeCurrencyCode(options.currency)\n const compact = Math.abs(value) >= 1_000\n const formatOptions: Intl.NumberFormatOptions = {\n style: code ? 'currency' : 'decimal',\n notation: compact ? 'compact' : 'standard',\n compactDisplay: 'short',\n minimumFractionDigits: compact ? 1 : 0,\n maximumFractionDigits: compact ? 1 : 0,\n }\n if (code) formatOptions.currency = code\n\n try {\n return getNumberFormatter(options.locale, formatOptions).format(value)\n } catch {\n const formatted = getNumberFormatter(options.locale, {\n style: 'decimal',\n notation: compact ? 'compact' : 'standard',\n compactDisplay: 'short',\n minimumFractionDigits: compact ? 1 : 0,\n maximumFractionDigits: compact ? 1 : 0,\n }).format(value)\n return code ? `${formatted} ${code}` : formatted\n }\n}\n\nexport function formatCurrencySafe(\n value: unknown,\n fallback = '--',\n options: FormatCurrencyOptions = {},\n): string {\n if (value === null || value === undefined) return fallback\n const num = Number(value)\n if (!Number.isFinite(num)) return fallback\n return formatCurrency(num, options)\n}\n\nexport type CurrencyFormatters = {\n currency: string | null\n format: (value: number) => string\n formatWithDecimals: (value: number) => string\n formatCompact: (value: number) => string\n formatSafe: (value: unknown) => string\n}\n\n/**\n * Binds the resolved currency once so widgets can hand chart and KPI components a\n * stable single-argument formatter \u2014 passing `formatCurrency` by reference is what\n * made its currency option unreachable in the first place (#4620).\n */\nexport function createCurrencyFormatters(\n currency?: string | null,\n fallback = '--',\n locale?: string,\n): CurrencyFormatters {\n const code = normalizeCurrencyCode(currency)\n return {\n currency: code,\n format: (value: number) => formatCurrency(value, { currency: code, locale }),\n formatWithDecimals: (value: number) => formatCurrencyWithDecimals(value, { currency: code, locale }),\n formatCompact: (value: number) => formatCurrencyCompact(value, { currency: code, locale }),\n formatSafe: (value: unknown) => formatCurrencySafe(value, fallback, { currency: code, locale }),\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function resolveOptionalBaseCurrencyResolver(container) {
|
|
2
|
+
try {
|
|
3
|
+
return container.resolve("baseCurrencyService");
|
|
4
|
+
} catch {
|
|
5
|
+
return void 0;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
resolveOptionalBaseCurrencyResolver
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=optionalBaseCurrency.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/dashboards/lib/optionalBaseCurrency.ts"],
|
|
4
|
+
"sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\n\nexport type BaseCurrencyResolution =\n | { status: 'resolved'; code: string }\n | { status: 'missing' | 'ambiguous' | 'unavailable' }\n\nexport type BaseCurrencyResolver = {\n resolveBaseCurrency(scope: {\n tenantId: string\n organizationIds: string[]\n }): Promise<BaseCurrencyResolution>\n}\n\nexport function resolveOptionalBaseCurrencyResolver(\n container: AppContainer,\n): BaseCurrencyResolver | undefined {\n try {\n return container.resolve<BaseCurrencyResolver>('baseCurrencyService')\n } catch {\n return undefined\n }\n}\n"],
|
|
5
|
+
"mappings": "AAaO,SAAS,oCACd,WACkC;AAClC,MAAI;AACF,WAAO,UAAU,QAA8B,qBAAqB;AAAA,EACtE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -15,6 +15,7 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
15
15
|
import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
16
16
|
import {
|
|
17
17
|
buildAggregationQuery,
|
|
18
|
+
buildDistinctCurrencyQuery,
|
|
18
19
|
buildGroupSourceRowsQuery,
|
|
19
20
|
resolveGroupExpression
|
|
20
21
|
} from "../lib/aggregations.js";
|
|
@@ -25,12 +26,12 @@ import {
|
|
|
25
26
|
exactDecimalToNumber,
|
|
26
27
|
parseExactDecimal
|
|
27
28
|
} from "../lib/exactDecimal.js";
|
|
29
|
+
const logger = createLogger("dashboards").child({ component: "widget-data-service" });
|
|
28
30
|
const WIDGET_DATA_CACHE_TTL = 12e4;
|
|
29
31
|
const WIDGET_DATA_SEGMENT_TTL = 864e5;
|
|
30
32
|
const WIDGET_DATA_SEGMENT_KEY = "widget-data:__segment__";
|
|
31
33
|
const ENCRYPTED_GROUP_SCAN_LIMIT = 2e4;
|
|
32
34
|
const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
33
|
-
const logger = createLogger("dashboards").child({ component: "widget-data" });
|
|
34
35
|
class WidgetDataValidationError extends Error {
|
|
35
36
|
constructor(message) {
|
|
36
37
|
super(message);
|
|
@@ -103,6 +104,7 @@ class WidgetDataService {
|
|
|
103
104
|
this.scope = options.scope;
|
|
104
105
|
this.registry = options.registry;
|
|
105
106
|
this.cache = options.cache;
|
|
107
|
+
this.baseCurrencyResolver = options.baseCurrencyResolver;
|
|
106
108
|
}
|
|
107
109
|
buildCacheKey(request) {
|
|
108
110
|
const hash = createHash("sha256");
|
|
@@ -134,16 +136,19 @@ class WidgetDataService {
|
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
const shouldFetchComparison = Boolean(comparisonRange && request.dateRange);
|
|
137
|
-
const [
|
|
139
|
+
const aggregatedRanges = shouldFetchComparison && comparisonRange ? [dateRangeResolved, comparisonRange] : [dateRangeResolved];
|
|
140
|
+
const [mainResult, comparisonResult, currency] = await Promise.all([
|
|
138
141
|
this.executeQuery(request, dateRangeResolved),
|
|
139
|
-
shouldFetchComparison && comparisonRange ? this.executeQuery(request, comparisonRange) : Promise.resolve(void 0)
|
|
142
|
+
shouldFetchComparison && comparisonRange ? this.executeQuery(request, comparisonRange) : Promise.resolve(void 0),
|
|
143
|
+
this.resolveCurrencyLabel(request, aggregatedRanges)
|
|
140
144
|
]);
|
|
141
145
|
const response = {
|
|
142
146
|
value: mainResult.value,
|
|
143
147
|
data: mainResult.data,
|
|
144
148
|
metadata: {
|
|
145
149
|
fetchedAt: now.toISOString(),
|
|
146
|
-
recordCount: mainResult.data.length || (mainResult.value !== null ? 1 : 0)
|
|
150
|
+
recordCount: mainResult.data.length || (mainResult.value !== null ? 1 : 0),
|
|
151
|
+
currency
|
|
147
152
|
}
|
|
148
153
|
};
|
|
149
154
|
if (comparisonResult && mainResult.value !== null && comparisonResult.value !== null) {
|
|
@@ -168,6 +173,81 @@ class WidgetDataService {
|
|
|
168
173
|
}
|
|
169
174
|
return response;
|
|
170
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Resolves the currency the response may be labelled with. The base currency of the
|
|
178
|
+
* scope is only half the answer: it describes how the organizations are configured, not
|
|
179
|
+
* what the aggregated rows are actually denominated in. A PLN-based organization holding
|
|
180
|
+
* both PLN and EUR orders would otherwise sum them and present the total as PLN (#4676),
|
|
181
|
+
* so the base currency survives only when every aggregated row carries exactly that code.
|
|
182
|
+
*
|
|
183
|
+
* The check is deliberately conservative — anything it cannot prove resolves to `null`
|
|
184
|
+
* and the widgets render an explicitly unlabelled number, which is recoverable, while a
|
|
185
|
+
* confident wrong label is not.
|
|
186
|
+
*/
|
|
187
|
+
async resolveCurrencyLabel(request, aggregatedRanges) {
|
|
188
|
+
const baseCurrency = await this.resolveBaseCurrency();
|
|
189
|
+
if (!baseCurrency) return null;
|
|
190
|
+
try {
|
|
191
|
+
for (const range of aggregatedRanges) {
|
|
192
|
+
const uniform = await this.rowsShareCurrency(request, range, baseCurrency);
|
|
193
|
+
if (!uniform) return null;
|
|
194
|
+
}
|
|
195
|
+
return baseCurrency;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.warn("Row-currency uniformity check failed; leaving the amount unlabelled", {
|
|
198
|
+
err,
|
|
199
|
+
entityType: request.entityType
|
|
200
|
+
});
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Reads the distinct per-row currencies of the rows one aggregation range would sum.
|
|
206
|
+
* Entities that declare no `currencyField` carry no per-row currency to contradict the
|
|
207
|
+
* base one, so they pass. An empty range has nothing to mislabel and also passes.
|
|
208
|
+
*
|
|
209
|
+
* A missing currency cannot prove the row uses the scope's base currency, so it fails
|
|
210
|
+
* closed just like a conflicting code. An empty range has no row to mislabel and passes.
|
|
211
|
+
*/
|
|
212
|
+
async rowsShareCurrency(request, dateRange, expectedCode) {
|
|
213
|
+
const query = buildDistinctCurrencyQuery({
|
|
214
|
+
entityType: request.entityType,
|
|
215
|
+
dateRange: dateRange && request.dateRange ? { field: request.dateRange.field, ...dateRange } : void 0,
|
|
216
|
+
filters: request.filters,
|
|
217
|
+
scope: this.scope,
|
|
218
|
+
registry: this.registry
|
|
219
|
+
});
|
|
220
|
+
if (!query) return true;
|
|
221
|
+
const rows = await this.em.getConnection().execute(query.sql, query.params);
|
|
222
|
+
const resultRows = Array.isArray(rows) ? rows : [];
|
|
223
|
+
const recordedCodes = /* @__PURE__ */ new Set();
|
|
224
|
+
for (const row of resultRows) {
|
|
225
|
+
const code = typeof row.code === "string" ? row.code.trim().toUpperCase() : "";
|
|
226
|
+
if (!code) return false;
|
|
227
|
+
recordedCodes.add(code);
|
|
228
|
+
}
|
|
229
|
+
if (recordedCodes.size === 0) return true;
|
|
230
|
+
if (recordedCodes.size > 1) return false;
|
|
231
|
+
return recordedCodes.has(expectedCode);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Resolves the base currency of the current scope so money widgets can label amounts
|
|
235
|
+
* with the tenant's own currency instead of a hard-coded default (#4620). The lookup
|
|
236
|
+
* is soft: a scope spanning organizations with different base currencies, a missing
|
|
237
|
+
* base currency, or an unavailable currencies module all resolve to `null`, and the
|
|
238
|
+
* widgets then render unlabelled numbers rather than a wrong currency.
|
|
239
|
+
*/
|
|
240
|
+
resolveBaseCurrency() {
|
|
241
|
+
if (!this.baseCurrencyPromise) {
|
|
242
|
+
const organizationIds = [...new Set(this.scope.organizationIds ?? [])];
|
|
243
|
+
if (!this.baseCurrencyResolver || organizationIds.length === 0) {
|
|
244
|
+
this.baseCurrencyPromise = Promise.resolve(null);
|
|
245
|
+
} else {
|
|
246
|
+
this.baseCurrencyPromise = this.baseCurrencyResolver.resolveBaseCurrency({ tenantId: this.scope.tenantId, organizationIds }).then((result) => result.status === "resolved" ? result.code : null).catch(() => null);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return this.baseCurrencyPromise;
|
|
250
|
+
}
|
|
171
251
|
validateRequest(request) {
|
|
172
252
|
if (!this.registry.isValidEntityType(request.entityType)) {
|
|
173
253
|
throw new WidgetDataValidationError(`Invalid entity type: ${request.entityType}`);
|
|
@@ -553,8 +633,8 @@ class WidgetDataService {
|
|
|
553
633
|
return decryptWithAesGcm(first, dek) ?? first;
|
|
554
634
|
}
|
|
555
635
|
}
|
|
556
|
-
function createWidgetDataService(em, scope, registry, cache) {
|
|
557
|
-
return new WidgetDataService({ em, scope, registry, cache });
|
|
636
|
+
function createWidgetDataService(em, scope, registry, cache, baseCurrencyResolver) {
|
|
637
|
+
return new WidgetDataService({ em, scope, registry, cache, baseCurrencyResolver });
|
|
558
638
|
}
|
|
559
639
|
export {
|
|
560
640
|
WidgetDataEncryptionUnavailableError,
|