@open-mercato/core 0.6.7-develop.6706.1.b3a4c759bb → 0.6.7-develop.6725.1.07a748cfc2
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/auth/api/admin/nav.js +14 -4
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +8 -19
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +31 -14
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/customers/api/utils.js +12 -18
- package/dist/modules/customers/api/utils.js.map +2 -2
- package/dist/modules/customers/cli.js +11 -4
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +14 -10
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/lib/aggregations.js +76 -40
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
- package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +228 -5
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
- package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
- package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
- package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
- package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
- package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
- package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
- package/dist/modules/messages/lib/searchLookup.js +8 -16
- package/dist/modules/messages/lib/searchLookup.js.map +2 -2
- package/dist/modules/query_index/api/openapi.js +13 -1
- package/dist/modules/query_index/api/openapi.js.map +2 -2
- package/dist/modules/query_index/api/status.js +165 -105
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
- package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
- package/dist/modules/query_index/lib/batch.js +11 -4
- package/dist/modules/query_index/lib/batch.js.map +2 -2
- package/dist/modules/query_index/lib/document.js +12 -5
- package/dist/modules/query_index/lib/document.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +36 -4
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +1 -1
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +7 -4
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +14 -3
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +22 -1
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
- package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
- package/dist/modules/sales/data/validators.js +25 -4
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/admin/nav.ts +24 -4
- package/src/modules/auth/api/users/route.ts +8 -28
- package/src/modules/auth/lib/backendChrome.tsx +51 -13
- package/src/modules/customers/api/utils.ts +12 -31
- package/src/modules/customers/cli.ts +11 -4
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
- package/src/modules/customers/components/formConfig.tsx +12 -8
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
- package/src/modules/dashboards/lib/aggregations.ts +122 -43
- package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
- package/src/modules/dashboards/services/widgetDataService.ts +342 -4
- package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
- package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
- package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
- package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
- package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
- package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
- package/src/modules/messages/lib/searchLookup.ts +11 -34
- package/src/modules/query_index/api/openapi.ts +12 -0
- package/src/modules/query_index/api/status.ts +238 -136
- package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
- package/src/modules/query_index/i18n/de.json +5 -3
- package/src/modules/query_index/i18n/en.json +5 -3
- package/src/modules/query_index/i18n/es.json +5 -3
- package/src/modules/query_index/i18n/pl.json +5 -3
- package/src/modules/query_index/lib/batch.ts +11 -4
- package/src/modules/query_index/lib/document.ts +35 -4
- package/src/modules/query_index/lib/engine.ts +53 -5
- package/src/modules/query_index/lib/indexer.ts +1 -1
- package/src/modules/query_index/lib/search-tokens.ts +13 -4
- package/src/modules/sales/api/documents/factory.ts +28 -3
- package/src/modules/sales/commands/documents.ts +26 -2
- package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
- package/src/modules/sales/data/validators.ts +50 -5
|
@@ -5,7 +5,9 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
6
|
import {
|
|
7
7
|
createWidgetDataService,
|
|
8
|
-
WidgetDataValidationError
|
|
8
|
+
WidgetDataValidationError,
|
|
9
|
+
WidgetDataScanLimitError,
|
|
10
|
+
WidgetDataEncryptionUnavailableError
|
|
9
11
|
} from "../../../../services/widgetDataService.js";
|
|
10
12
|
import { runWidgetDataBatch } from "../../../../lib/widgetDataBatch.js";
|
|
11
13
|
import { dashboardsTag, dashboardsErrorSchema } from "../../../openapi.js";
|
|
@@ -91,7 +93,7 @@ async function POST(req) {
|
|
|
91
93
|
});
|
|
92
94
|
},
|
|
93
95
|
fetchOne: (request) => service.fetchWidgetData(request),
|
|
94
|
-
describeError: (error) => error instanceof WidgetDataValidationError ? error.message : "An error occurred while processing your request"
|
|
96
|
+
describeError: (error) => error instanceof WidgetDataValidationError || error instanceof WidgetDataScanLimitError || error instanceof WidgetDataEncryptionUnavailableError ? error.message : "An error occurred while processing your request"
|
|
95
97
|
});
|
|
96
98
|
return NextResponse.json({ results });
|
|
97
99
|
} catch (err) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/dashboards/api/widgets/data/batch/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport {\n createWidgetDataService,\n type WidgetDataRequest,\n WidgetDataValidationError,\n} from '../../../../services/widgetDataService'\nimport { runWidgetDataBatch } from '../../../../lib/widgetDataBatch'\nimport type { AnalyticsRegistry } from '../../../../services/analyticsRegistry'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { dashboardsTag, dashboardsErrorSchema } from '../../../openapi'\nimport { widgetDataRequestSchema, widgetDataResponseSchema } from '../schema'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('dashboards').child({ component: 'widgets-data-batch' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['analytics.view'] },\n}\n\nconst MAX_BATCH_SIZE = 50\n\nconst widgetDataBatchRequestSchema = z.object({\n requests: z\n .array(\n z.object({\n id: z.string().min(1),\n request: widgetDataRequestSchema,\n }),\n )\n .min(1)\n .max(MAX_BATCH_SIZE),\n})\n\nconst widgetDataBatchResponseSchema = z.object({\n results: z.array(\n z.discriminatedUnion('ok', [\n z.object({\n id: z.string(),\n ok: z.literal(true),\n data: widgetDataResponseSchema,\n }),\n z.object({\n id: z.string(),\n ok: z.literal(false),\n error: z.string(),\n }),\n ]),\n ),\n})\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })\n }\n\n const parsed = widgetDataBatchRequestSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Invalid request payload', issues: parsed.error.issues },\n { status: 400 },\n )\n }\n\n const tenantId = auth.tenantId ?? null\n if (!tenantId) {\n return NextResponse.json({ error: 'Tenant context is required' }, { status: 400 })\n }\n\n // Build the per-request DI/RBAC/org-scope stack exactly once for the whole\n // batch instead of once per widget (see issue #2273).\n const container = await createRequestContainer()\n const analyticsRegistry = container.resolve<AnalyticsRegistry>('analyticsRegistry')\n\n const em = (container.resolve('em') as EntityManager).fork({\n clear: true,\n freshEventManager: true,\n useContext: true,\n })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n\n const organizationIds = (() => {\n if (scope?.selectedId) return [scope.selectedId]\n if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds\n if (scope?.allowedIds === null) return undefined\n if (auth.orgId) return [auth.orgId]\n return undefined\n })()\n\n const cache = container.resolve<CacheStrategy>('cache')\n const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)\n\n const rbacService = container.resolve<{\n userHasAllFeatures: (\n userId: string,\n features: string[],\n scope: { tenantId: string; organizationId?: string | null },\n ) => Promise<boolean>\n }>('rbacService')\n\n try {\n const results = await runWidgetDataBatch(parsed.data.requests as Array<{ id: string; request: WidgetDataRequest }>, {\n getRequiredFeatures: (entityType) => analyticsRegistry.getRequiredFeatures(entityType),\n checkFeatures: (features) => {\n if (features.length === 0) return Promise.resolve(true)\n return rbacService.userHasAllFeatures(auth.sub, features, {\n tenantId,\n organizationId: auth.orgId,\n })\n },\n fetchOne: (request) => service.fetchWidgetData(request),\n describeError: (error) =>\n error instanceof WidgetDataValidationError\n ? error.message\n : 'An error occurred while processing your request',\n })\n return NextResponse.json({ results })\n } catch (err) {\n logger.error('Widget batch data request failed', { err })\n return NextResponse.json(\n { error: 'An error occurred while processing your request' },\n { status: 500 },\n )\n }\n}\n\nconst widgetDataBatchPostDoc: OpenApiMethodDoc = {\n summary: 'Fetch aggregated data for multiple dashboard widgets in one request',\n description:\n 'Resolves a batch of widget data requests with a single authentication, RBAC, organization-scope, and database-context setup. Each request is keyed by an opaque widget id and resolved independently, so a failure in one widget does not fail the batch.',\n tags: [dashboardsTag],\n requestBody: {\n contentType: 'application/json',\n schema: widgetDataBatchRequestSchema,\n description: 'A list of id-keyed widget data requests to resolve together.',\n },\n responses: [\n {\n status: 200,\n description: 'Per-widget aggregation results keyed by request id.',\n schema: widgetDataBatchResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },\n { status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },\n { status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: dashboardsTag,\n summary: 'Batch widget data aggregation endpoint',\n methods: {\n POST: widgetDataBatchPostDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,0CAA0C;AACnD;AAAA,EACE;AAAA,EAEA;AAAA,OACK;AACP,SAAS,0BAA0B;AAGnC,SAAS,eAAe,6BAA6B;AACrD,SAAS,yBAAyB,gCAAgC;AAClE,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AAE5E,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AACjE;AAEA,MAAM,iBAAiB;AAEvB,MAAM,+BAA+B,EAAE,OAAO;AAAA,EAC5C,UAAU,EACP;AAAA,IACC,EAAE,OAAO;AAAA,MACP,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACpB,SAAS;AAAA,IACX,CAAC;AAAA,EACH,EACC,IAAI,CAAC,EACL,IAAI,cAAc;AACvB,CAAC;AAED,MAAM,gCAAgC,EAAE,OAAO;AAAA,EAC7C,SAAS,EAAE;AAAA,IACT,EAAE,mBAAmB,MAAM;AAAA,MACzB,EAAE,OAAO;AAAA,QACP,IAAI,EAAE,OAAO;AAAA,QACb,IAAI,EAAE,QAAQ,IAAI;AAAA,QAClB,MAAM;AAAA,MACR,CAAC;AAAA,MACD,EAAE,OAAO;AAAA,QACP,IAAI,EAAE,OAAO;AAAA,QACb,IAAI,EAAE,QAAQ,KAAK;AAAA,QACnB,OAAO,EAAE,OAAO;AAAA,MAClB,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AACF,CAAC;AAED,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM;AACT,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,IAAI,KAAK;AAAA,EACxB,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,OAAO,oBAAoB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1E;AAEA,QAAM,SAAS,6BAA6B,UAAU,IAAI;AAC1D,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,2BAA2B,QAAQ,OAAO,MAAM,OAAO;AAAA,MAChE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,WAAW,KAAK,YAAY;AAClC,MAAI,CAAC,UAAU;AACb,WAAO,aAAa,KAAK,EAAE,OAAO,6BAA6B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnF;AAIA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,oBAAoB,UAAU,QAA2B,mBAAmB;AAElF,QAAM,KAAM,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAAA,IACzD,OAAO;AAAA,IACP,mBAAmB;AAAA,IACnB,YAAY;AAAA,EACd,CAAC;AAED,QAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AAExF,QAAM,mBAAmB,MAAM;AAC7B,QAAI,OAAO,WAAY,QAAO,CAAC,MAAM,UAAU;AAC/C,QAAI,MAAM,QAAQ,OAAO,SAAS,KAAK,MAAM,UAAU,SAAS,EAAG,QAAO,MAAM;AAChF,QAAI,OAAO,eAAe,KAAM,QAAO;AACvC,QAAI,KAAK,MAAO,QAAO,CAAC,KAAK,KAAK;AAClC,WAAO;AAAA,EACT,GAAG;AAEH,QAAM,QAAQ,UAAU,QAAuB,OAAO;AACtD,QAAM,UAAU,wBAAwB,IAAI,EAAE,UAAU,gBAAgB,GAAG,mBAAmB,KAAK;AAEnG,QAAM,cAAc,UAAU,QAM3B,aAAa;AAEhB,MAAI;AACF,UAAM,UAAU,MAAM,mBAAmB,OAAO,KAAK,UAA+D;AAAA,MAClH,qBAAqB,CAAC,eAAe,kBAAkB,oBAAoB,UAAU;AAAA,MACrF,eAAe,CAAC,aAAa;AAC3B,YAAI,SAAS,WAAW,EAAG,QAAO,QAAQ,QAAQ,IAAI;AACtD,eAAO,YAAY,mBAAmB,KAAK,KAAK,UAAU;AAAA,UACxD;AAAA,UACA,gBAAgB,KAAK;AAAA,QACvB,CAAC;AAAA,MACH;AAAA,MACA,UAAU,CAAC,YAAY,QAAQ,gBAAgB,OAAO;AAAA,MACtD,eAAe,CAAC,UACd,iBAAiB,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport {\n createWidgetDataService,\n type WidgetDataRequest,\n WidgetDataValidationError,\n WidgetDataScanLimitError,\n WidgetDataEncryptionUnavailableError,\n} from '../../../../services/widgetDataService'\nimport { runWidgetDataBatch } from '../../../../lib/widgetDataBatch'\nimport type { AnalyticsRegistry } from '../../../../services/analyticsRegistry'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { dashboardsTag, dashboardsErrorSchema } from '../../../openapi'\nimport { widgetDataRequestSchema, widgetDataResponseSchema } from '../schema'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('dashboards').child({ component: 'widgets-data-batch' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['analytics.view'] },\n}\n\nconst MAX_BATCH_SIZE = 50\n\nconst widgetDataBatchRequestSchema = z.object({\n requests: z\n .array(\n z.object({\n id: z.string().min(1),\n request: widgetDataRequestSchema,\n }),\n )\n .min(1)\n .max(MAX_BATCH_SIZE),\n})\n\nconst widgetDataBatchResponseSchema = z.object({\n results: z.array(\n z.discriminatedUnion('ok', [\n z.object({\n id: z.string(),\n ok: z.literal(true),\n data: widgetDataResponseSchema,\n }),\n z.object({\n id: z.string(),\n ok: z.literal(false),\n error: z.string(),\n }),\n ]),\n ),\n})\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })\n }\n\n const parsed = widgetDataBatchRequestSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Invalid request payload', issues: parsed.error.issues },\n { status: 400 },\n )\n }\n\n const tenantId = auth.tenantId ?? null\n if (!tenantId) {\n return NextResponse.json({ error: 'Tenant context is required' }, { status: 400 })\n }\n\n // Build the per-request DI/RBAC/org-scope stack exactly once for the whole\n // batch instead of once per widget (see issue #2273).\n const container = await createRequestContainer()\n const analyticsRegistry = container.resolve<AnalyticsRegistry>('analyticsRegistry')\n\n const em = (container.resolve('em') as EntityManager).fork({\n clear: true,\n freshEventManager: true,\n useContext: true,\n })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n\n const organizationIds = (() => {\n if (scope?.selectedId) return [scope.selectedId]\n if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds\n if (scope?.allowedIds === null) return undefined\n if (auth.orgId) return [auth.orgId]\n return undefined\n })()\n\n const cache = container.resolve<CacheStrategy>('cache')\n const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)\n\n const rbacService = container.resolve<{\n userHasAllFeatures: (\n userId: string,\n features: string[],\n scope: { tenantId: string; organizationId?: string | null },\n ) => Promise<boolean>\n }>('rbacService')\n\n try {\n const results = await runWidgetDataBatch(parsed.data.requests as Array<{ id: string; request: WidgetDataRequest }>, {\n getRequiredFeatures: (entityType) => analyticsRegistry.getRequiredFeatures(entityType),\n checkFeatures: (features) => {\n if (features.length === 0) return Promise.resolve(true)\n return rbacService.userHasAllFeatures(auth.sub, features, {\n tenantId,\n organizationId: auth.orgId,\n })\n },\n fetchOne: (request) => service.fetchWidgetData(request),\n describeError: (error) =>\n error instanceof WidgetDataValidationError\n || error instanceof WidgetDataScanLimitError\n || error instanceof WidgetDataEncryptionUnavailableError\n ? error.message\n : 'An error occurred while processing your request',\n })\n return NextResponse.json({ results })\n } catch (err) {\n logger.error('Widget batch data request failed', { err })\n return NextResponse.json(\n { error: 'An error occurred while processing your request' },\n { status: 500 },\n )\n }\n}\n\nconst widgetDataBatchPostDoc: OpenApiMethodDoc = {\n summary: 'Fetch aggregated data for multiple dashboard widgets in one request',\n description:\n 'Resolves a batch of widget data requests with a single authentication, RBAC, organization-scope, and database-context setup. Each request is keyed by an opaque widget id and resolved independently, so a failure in one widget does not fail the batch.',\n tags: [dashboardsTag],\n requestBody: {\n contentType: 'application/json',\n schema: widgetDataBatchRequestSchema,\n description: 'A list of id-keyed widget data requests to resolve together.',\n },\n responses: [\n {\n status: 200,\n description: 'Per-widget aggregation results keyed by request id.',\n schema: widgetDataBatchResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },\n { status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },\n { status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: dashboardsTag,\n summary: 'Batch widget data aggregation endpoint',\n methods: {\n POST: widgetDataBatchPostDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,7 +4,9 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
|
4
4
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
5
5
|
import {
|
|
6
6
|
createWidgetDataService,
|
|
7
|
-
WidgetDataValidationError
|
|
7
|
+
WidgetDataValidationError,
|
|
8
|
+
WidgetDataScanLimitError,
|
|
9
|
+
WidgetDataEncryptionUnavailableError
|
|
8
10
|
} from "../../../services/widgetDataService.js";
|
|
9
11
|
import { runApiInterceptorsBefore } from "@open-mercato/shared/lib/crud/interceptor-runner";
|
|
10
12
|
import { dashboardsTag, dashboardsErrorSchema } from "../../openapi.js";
|
|
@@ -99,6 +101,12 @@ async function POST(req) {
|
|
|
99
101
|
if (err instanceof WidgetDataValidationError) {
|
|
100
102
|
return NextResponse.json({ error: err.message }, { status: 400 });
|
|
101
103
|
}
|
|
104
|
+
if (err instanceof WidgetDataScanLimitError) {
|
|
105
|
+
return NextResponse.json({ error: err.message }, { status: 422 });
|
|
106
|
+
}
|
|
107
|
+
if (err instanceof WidgetDataEncryptionUnavailableError) {
|
|
108
|
+
return NextResponse.json({ error: err.message }, { status: 503 });
|
|
109
|
+
}
|
|
102
110
|
return NextResponse.json(
|
|
103
111
|
{ error: "An error occurred while processing your request" },
|
|
104
112
|
{ status: 500 }
|
|
@@ -124,8 +132,18 @@ const widgetDataPostDoc = {
|
|
|
124
132
|
errors: [
|
|
125
133
|
{ status: 400, description: "Invalid request payload", schema: dashboardsErrorSchema },
|
|
126
134
|
{ status: 401, description: "Authentication required", schema: dashboardsErrorSchema },
|
|
135
|
+
{
|
|
136
|
+
status: 422,
|
|
137
|
+
description: "Too many rows to group an encrypted field in application code",
|
|
138
|
+
schema: dashboardsErrorSchema
|
|
139
|
+
},
|
|
127
140
|
{ status: 403, description: "Missing analytics.view feature", schema: dashboardsErrorSchema },
|
|
128
|
-
{ status: 500, description: "Internal server error", schema: dashboardsErrorSchema }
|
|
141
|
+
{ status: 500, description: "Internal server error", schema: dashboardsErrorSchema },
|
|
142
|
+
{
|
|
143
|
+
status: 503,
|
|
144
|
+
description: "Encryption is configured but the group source cannot currently be resolved",
|
|
145
|
+
schema: dashboardsErrorSchema
|
|
146
|
+
}
|
|
129
147
|
]
|
|
130
148
|
};
|
|
131
149
|
const openApi = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/dashboards/api/widgets/data/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport {\n createWidgetDataService,\n type WidgetDataRequest,\n WidgetDataValidationError,\n} from '../../../services/widgetDataService'\nimport type { AnalyticsRegistry } from '../../../services/analyticsRegistry'\nimport { runApiInterceptorsBefore } from '@open-mercato/shared/lib/crud/interceptor-runner'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { dashboardsTag, dashboardsErrorSchema } from '../../openapi'\nimport { widgetDataRequestSchema, widgetDataResponseSchema } from './schema'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('dashboards').child({ component: 'widgets-data' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['analytics.view'] },\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })\n }\n\n const parsed = widgetDataRequestSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Invalid request payload', issues: parsed.error.issues },\n { status: 400 },\n )\n }\n\n const container = await createRequestContainer()\n const analyticsRegistry = container.resolve<AnalyticsRegistry>('analyticsRegistry')\n\n const entityFeatures = analyticsRegistry.getRequiredFeatures(parsed.data.entityType)\n if (entityFeatures && entityFeatures.length > 0) {\n const rbacService = container.resolve<{\n userHasAllFeatures: (\n userId: string,\n features: string[],\n scope: { tenantId: string; organizationId?: string | null },\n ) => Promise<boolean>\n }>('rbacService')\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, entityFeatures, {\n tenantId: auth.tenantId!,\n organizationId: auth.orgId,\n })\n if (!hasAccess) {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n }\n\n const em = (container.resolve('em') as EntityManager).fork({\n clear: true,\n freshEventManager: true,\n useContext: true,\n })\n\n const tenantId = auth.tenantId ?? null\n if (!tenantId) {\n return NextResponse.json({ error: 'Tenant context is required' }, { status: 400 })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n\n const organizationIds = (() => {\n if (scope?.selectedId) return [scope.selectedId]\n if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds\n if (scope?.allowedIds === null) return undefined\n if (auth.orgId) return [auth.orgId]\n return undefined\n })()\n\n const userFeatures = Array.isArray(auth.features)\n ? auth.features.filter((value): value is string => typeof value === 'string')\n : []\n\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const interceptorResult = await runApiInterceptorsBefore({\n routePath: 'dashboards/widgets/data',\n method: 'POST',\n request: {\n method: 'POST',\n url: req.url,\n headers,\n body: parsed.data as unknown as Record<string, unknown>,\n },\n context: {\n em,\n container,\n userId: auth.sub ?? '',\n tenantId,\n organizationId: organizationIds?.[0] ?? auth.orgId ?? '',\n userFeatures,\n },\n })\n\n if (!interceptorResult.ok) {\n return NextResponse.json(interceptorResult.body, { status: interceptorResult.statusCode })\n }\n\n const requestData = (interceptorResult.request.body ?? parsed.data) as WidgetDataRequest\n\n try {\n const cache = container.resolve<CacheStrategy>('cache')\n const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)\n const result = await service.fetchWidgetData(requestData)\n return NextResponse.json(result)\n } catch (err) {\n logger.error('Widget data request failed', { err })\n if (err instanceof WidgetDataValidationError) {\n return NextResponse.json({ error: err.message }, { status: 400 })\n }\n return NextResponse.json(\n { error: 'An error occurred while processing your request' },\n { status: 500 },\n )\n }\n}\n\nconst widgetDataPostDoc: OpenApiMethodDoc = {\n summary: 'Fetch aggregated data for dashboard widgets',\n description:\n 'Executes an aggregation query against the specified entity type and returns the result. Supports date range filtering, grouping, and period-over-period comparison.',\n tags: [dashboardsTag],\n requestBody: {\n contentType: 'application/json',\n schema: widgetDataRequestSchema,\n description: 'Widget data request configuration specifying entity type, metric, filters, and grouping.',\n },\n responses: [\n {\n status: 200,\n description: 'Aggregated data for the widget.',\n schema: widgetDataResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },\n { status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },\n { status: 403, description: 'Missing analytics.view feature', schema: dashboardsErrorSchema },\n { status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: dashboardsTag,\n summary: 'Widget data aggregation endpoint',\n methods: {\n POST: widgetDataPostDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport {\n createWidgetDataService,\n type WidgetDataRequest,\n WidgetDataValidationError,\n WidgetDataScanLimitError,\n WidgetDataEncryptionUnavailableError,\n} from '../../../services/widgetDataService'\nimport type { AnalyticsRegistry } from '../../../services/analyticsRegistry'\nimport { runApiInterceptorsBefore } from '@open-mercato/shared/lib/crud/interceptor-runner'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { dashboardsTag, dashboardsErrorSchema } from '../../openapi'\nimport { widgetDataRequestSchema, widgetDataResponseSchema } from './schema'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('dashboards').child({ component: 'widgets-data' })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['analytics.view'] },\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })\n }\n\n const parsed = widgetDataRequestSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Invalid request payload', issues: parsed.error.issues },\n { status: 400 },\n )\n }\n\n const container = await createRequestContainer()\n const analyticsRegistry = container.resolve<AnalyticsRegistry>('analyticsRegistry')\n\n const entityFeatures = analyticsRegistry.getRequiredFeatures(parsed.data.entityType)\n if (entityFeatures && entityFeatures.length > 0) {\n const rbacService = container.resolve<{\n userHasAllFeatures: (\n userId: string,\n features: string[],\n scope: { tenantId: string; organizationId?: string | null },\n ) => Promise<boolean>\n }>('rbacService')\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, entityFeatures, {\n tenantId: auth.tenantId!,\n organizationId: auth.orgId,\n })\n if (!hasAccess) {\n return NextResponse.json({ error: 'Forbidden' }, { status: 403 })\n }\n }\n\n const em = (container.resolve('em') as EntityManager).fork({\n clear: true,\n freshEventManager: true,\n useContext: true,\n })\n\n const tenantId = auth.tenantId ?? null\n if (!tenantId) {\n return NextResponse.json({ error: 'Tenant context is required' }, { status: 400 })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n\n const organizationIds = (() => {\n if (scope?.selectedId) return [scope.selectedId]\n if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds\n if (scope?.allowedIds === null) return undefined\n if (auth.orgId) return [auth.orgId]\n return undefined\n })()\n\n const userFeatures = Array.isArray(auth.features)\n ? auth.features.filter((value): value is string => typeof value === 'string')\n : []\n\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const interceptorResult = await runApiInterceptorsBefore({\n routePath: 'dashboards/widgets/data',\n method: 'POST',\n request: {\n method: 'POST',\n url: req.url,\n headers,\n body: parsed.data as unknown as Record<string, unknown>,\n },\n context: {\n em,\n container,\n userId: auth.sub ?? '',\n tenantId,\n organizationId: organizationIds?.[0] ?? auth.orgId ?? '',\n userFeatures,\n },\n })\n\n if (!interceptorResult.ok) {\n return NextResponse.json(interceptorResult.body, { status: interceptorResult.statusCode })\n }\n\n const requestData = (interceptorResult.request.body ?? parsed.data) as WidgetDataRequest\n\n try {\n const cache = container.resolve<CacheStrategy>('cache')\n const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)\n const result = await service.fetchWidgetData(requestData)\n return NextResponse.json(result)\n } catch (err) {\n logger.error('Widget data request failed', { err })\n if (err instanceof WidgetDataValidationError) {\n return NextResponse.json({ error: err.message }, { status: 400 })\n }\n // The dataset is too large to group in application code; surface why rather than a masked 500.\n if (err instanceof WidgetDataScanLimitError) {\n return NextResponse.json({ error: err.message }, { status: 422 })\n }\n // Encryption is configured but currently unresolvable. Refusing the request keeps ciphertext and\n // silently-wrong buckets out of the response, and 503 tells the client it is worth retrying.\n if (err instanceof WidgetDataEncryptionUnavailableError) {\n return NextResponse.json({ error: err.message }, { status: 503 })\n }\n return NextResponse.json(\n { error: 'An error occurred while processing your request' },\n { status: 500 },\n )\n }\n}\n\nconst widgetDataPostDoc: OpenApiMethodDoc = {\n summary: 'Fetch aggregated data for dashboard widgets',\n description:\n 'Executes an aggregation query against the specified entity type and returns the result. Supports date range filtering, grouping, and period-over-period comparison.',\n tags: [dashboardsTag],\n requestBody: {\n contentType: 'application/json',\n schema: widgetDataRequestSchema,\n description: 'Widget data request configuration specifying entity type, metric, filters, and grouping.',\n },\n responses: [\n {\n status: 200,\n description: 'Aggregated data for the widget.',\n schema: widgetDataResponseSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },\n { status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },\n {\n status: 422,\n description: 'Too many rows to group an encrypted field in application code',\n schema: dashboardsErrorSchema,\n },\n { status: 403, description: 'Missing analytics.view feature', schema: dashboardsErrorSchema },\n { status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },\n {\n status: 503,\n description: 'Encryption is configured but the group source cannot currently be resolved',\n schema: dashboardsErrorSchema,\n },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: dashboardsTag,\n summary: 'Widget data aggregation endpoint',\n methods: {\n POST: widgetDataPostDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -46,44 +46,32 @@ function buildJsonbFieldExpression(column, path) {
|
|
|
46
46
|
const lastPart = parts[parts.length - 1];
|
|
47
47
|
return `${column}->${intermediate}->>'${lastPart}'`;
|
|
48
48
|
}
|
|
49
|
-
function
|
|
49
|
+
function resolveGroupExpression(registry, entityType, groupBy) {
|
|
50
|
+
const groupMapping = registry.getFieldMapping(entityType, groupBy.field);
|
|
51
|
+
if (!groupMapping && groupBy.field.includes(".")) {
|
|
52
|
+
const [baseField, ...pathParts] = groupBy.field.split(".");
|
|
53
|
+
const baseMapping = registry.getFieldMapping(entityType, baseField);
|
|
54
|
+
if (baseMapping?.type !== "jsonb") return null;
|
|
55
|
+
const jsonPath = pathParts.join(".");
|
|
56
|
+
return {
|
|
57
|
+
expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),
|
|
58
|
+
dbColumn: baseMapping.dbColumn,
|
|
59
|
+
jsonPath
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (!groupMapping) return null;
|
|
63
|
+
if (groupMapping.type === "timestamp" && groupBy.granularity) {
|
|
64
|
+
return {
|
|
65
|
+
expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),
|
|
66
|
+
dbColumn: groupMapping.dbColumn,
|
|
67
|
+
jsonPath: null
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null };
|
|
71
|
+
}
|
|
72
|
+
function buildWhereClause(options) {
|
|
50
73
|
const { registry } = options;
|
|
51
|
-
const config = registry.getEntityTypeConfig(options.entityType);
|
|
52
|
-
if (!config) return null;
|
|
53
|
-
const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field);
|
|
54
|
-
if (!metricMapping) return null;
|
|
55
74
|
const params = [];
|
|
56
|
-
const tableName = config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`;
|
|
57
|
-
const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn);
|
|
58
|
-
let selectClause = `SELECT ${aggregateExpr} AS value`;
|
|
59
|
-
let groupByClause = "";
|
|
60
|
-
let orderByClause = "";
|
|
61
|
-
let limitClause = "";
|
|
62
|
-
if (options.groupBy) {
|
|
63
|
-
let groupMapping = registry.getFieldMapping(options.entityType, options.groupBy.field);
|
|
64
|
-
let groupExpr = null;
|
|
65
|
-
if (!groupMapping && options.groupBy.field.includes(".")) {
|
|
66
|
-
const [baseField, ...pathParts] = options.groupBy.field.split(".");
|
|
67
|
-
const baseMapping = registry.getFieldMapping(options.entityType, baseField);
|
|
68
|
-
if (baseMapping?.type === "jsonb") {
|
|
69
|
-
groupExpr = buildJsonbFieldExpression(baseMapping.dbColumn, pathParts.join("."));
|
|
70
|
-
}
|
|
71
|
-
} else if (groupMapping) {
|
|
72
|
-
if (groupMapping.type === "timestamp" && options.groupBy.granularity) {
|
|
73
|
-
groupExpr = buildDateTruncExpression(groupMapping.dbColumn, options.groupBy.granularity);
|
|
74
|
-
} else {
|
|
75
|
-
groupExpr = groupMapping.dbColumn;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (groupExpr) {
|
|
79
|
-
selectClause = `SELECT ${groupExpr} AS group_key, ${aggregateExpr} AS value`;
|
|
80
|
-
groupByClause = `GROUP BY ${groupExpr}`;
|
|
81
|
-
orderByClause = `ORDER BY value DESC`;
|
|
82
|
-
if (options.groupBy.limit && options.groupBy.limit > 0) {
|
|
83
|
-
limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
75
|
const whereClauses = [];
|
|
88
76
|
whereClauses.push(`tenant_id = ?`);
|
|
89
77
|
params.push(options.scope.tenantId);
|
|
@@ -147,16 +135,64 @@ function buildAggregationQuery(options) {
|
|
|
147
135
|
}
|
|
148
136
|
}
|
|
149
137
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
138
|
+
return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "", params };
|
|
139
|
+
}
|
|
140
|
+
function resolveTableName(config) {
|
|
141
|
+
return config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`;
|
|
142
|
+
}
|
|
143
|
+
function buildAggregationQuery(options) {
|
|
144
|
+
const { registry } = options;
|
|
145
|
+
const config = registry.getEntityTypeConfig(options.entityType);
|
|
146
|
+
if (!config) return null;
|
|
147
|
+
const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field);
|
|
148
|
+
if (!metricMapping) return null;
|
|
149
|
+
const tableName = resolveTableName(config);
|
|
150
|
+
const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn);
|
|
151
|
+
let selectClause = `SELECT ${aggregateExpr} AS value`;
|
|
152
|
+
let groupByClause = "";
|
|
153
|
+
let orderByClause = "";
|
|
154
|
+
let limitClause = "";
|
|
155
|
+
if (options.groupBy) {
|
|
156
|
+
const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy);
|
|
157
|
+
if (resolved) {
|
|
158
|
+
selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`;
|
|
159
|
+
groupByClause = `GROUP BY ${resolved.expression}`;
|
|
160
|
+
orderByClause = `ORDER BY value DESC NULLS LAST`;
|
|
161
|
+
if (options.groupBy.limit && options.groupBy.limit > 0) {
|
|
162
|
+
limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const where = buildWhereClause(options);
|
|
167
|
+
const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause].filter(Boolean).join(" ");
|
|
168
|
+
return { sql, params: where.params };
|
|
169
|
+
}
|
|
170
|
+
function buildGroupSourceRowsQuery(options) {
|
|
171
|
+
const { registry } = options;
|
|
172
|
+
const config = registry.getEntityTypeConfig(options.entityType);
|
|
173
|
+
if (!config) return null;
|
|
174
|
+
const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field);
|
|
175
|
+
if (!metricMapping) return null;
|
|
176
|
+
if (!isSafeIdentifier(options.groupColumn)) {
|
|
177
|
+
throw new Error(`Invalid group column: ${options.groupColumn}`);
|
|
178
|
+
}
|
|
179
|
+
const where = buildWhereClause(options);
|
|
180
|
+
const sql = [
|
|
181
|
+
`SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,
|
|
182
|
+
`FROM ${resolveTableName(config)}`,
|
|
183
|
+
where.clause,
|
|
184
|
+
`LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`
|
|
185
|
+
].filter(Boolean).join(" ");
|
|
186
|
+
return { sql, params: where.params };
|
|
153
187
|
}
|
|
154
188
|
export {
|
|
155
189
|
buildAggregateExpression,
|
|
156
190
|
buildAggregationQuery,
|
|
157
191
|
buildDateTruncExpression,
|
|
192
|
+
buildGroupSourceRowsQuery,
|
|
158
193
|
buildJsonbFieldExpression,
|
|
159
194
|
isValidAggregate,
|
|
160
|
-
isValidGranularity
|
|
195
|
+
isValidGranularity,
|
|
196
|
+
resolveGroupExpression
|
|
161
197
|
};
|
|
162
198
|
//# sourceMappingURL=aggregations.js.map
|
|
@@ -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 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 params: unknown[] = []\n\n const tableName = config.schema ? `\"${config.schema}\".\"${config.tableName}\"` : `\"${config.tableName}\"`\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 let groupMapping = registry.getFieldMapping(options.entityType, options.groupBy.field)\n let groupExpr: string | null = null\n\n // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)\n if (!groupMapping && options.groupBy.field.includes('.')) {\n const [baseField, ...pathParts] = options.groupBy.field.split('.')\n const baseMapping = registry.getFieldMapping(options.entityType, baseField)\n if (baseMapping?.type === 'jsonb') {\n groupExpr = buildJsonbFieldExpression(baseMapping.dbColumn, pathParts.join('.'))\n }\n } else if (groupMapping) {\n if (groupMapping.type === 'timestamp' && options.groupBy.granularity) {\n groupExpr = buildDateTruncExpression(groupMapping.dbColumn, options.groupBy.granularity)\n } else {\n groupExpr = groupMapping.dbColumn\n }\n }\n\n if (groupExpr) {\n selectClause = `SELECT ${groupExpr} AS group_key, ${aggregateExpr} AS value`\n groupByClause = `GROUP BY ${groupExpr}`\n orderByClause = `ORDER BY value DESC`\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 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 const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''\n\n const sql = [selectClause, `FROM ${tableName}`, whereClause, groupByClause, orderByClause, limitClause]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params }\n}\n"],
|
|
5
|
-
"mappings": "AAMA,MAAM,sBAAkD,CAAC,OAAO,QAAQ,SAAS,WAAW,MAAM;AAClG,MAAM,mBAAiD,CAAC,SAAS,OAAO,OAAO,OAAO,KAAK;AAC3F,MAAM,0BAA0B;AAEzB,SAAS,mBAAmB,OAA0C;AAC3E,SAAO,OAAO,UAAU,YAAY,oBAAoB,SAAS,KAAwB;AAC3F;AAEO,SAAS,iBAAiB,OAA4C;AAC3E,SAAO,OAAO,UAAU,YAAY,iBAAiB,SAAS,KAA0B;AAC1F;AAEA,SAAS,iBAAiB,OAAwB;AAChD,SAAO,wBAAwB,KAAK,KAAK;AAC3C;AAMO,SAAS,yBAAyB,WAA8B,QAAwB;AAC7F,UAAQ,WAAW;AAAA,IACjB,KAAK;AACH,aAAO,WAAW,OAAO,aAAa,SAAS,MAAM;AAAA,IACvD,KAAK;AACH,aAAO,gBAAgB,MAAM;AAAA,IAC/B,KAAK;AACH,aAAO,gBAAgB,MAAM;AAAA,IAC/B,KAAK;AACH,aAAO,OAAO,MAAM;AAAA,IACtB,KAAK;AACH,aAAO,OAAO,MAAM;AAAA,IACtB;AACE,aAAO;AAAA,EACX;AACF;AAEO,SAAS,yBAAyB,QAAgB,aAAsC;AAC7F,MAAI,CAAC,mBAAmB,WAAW,GAAG;AACpC,UAAM,IAAI,MAAM,wBAAwB,WAAW,EAAE;AAAA,EACvD;AACA,SAAO,eAAe,WAAW,MAAM,MAAM;AAC/C;AAEO,SAAS,0BAA0B,QAAgB,MAAsB;AAC9E,QAAM,QAAQ,KAAK,MAAM,GAAG;AAC5B,aAAW,QAAQ,OAAO;AACxB,QAAI,CAAC,iBAAiB,IAAI,GAAG;AAC3B,YAAM,IAAI,MAAM,4BAA4B,IAAI,EAAE;AAAA,IACpD;AAAA,EACF;AACA,MAAI,MAAM,WAAW,GAAG;AACtB,WAAO,GAAG,MAAM,OAAO,MAAM,CAAC,CAAC;AAAA,EACjC;AACA,QAAM,eAAe,MAAM,MAAM,GAAG,EAAE,EAAE,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,EAAE,KAAK,IAAI;AACtE,QAAM,WAAW,MAAM,MAAM,SAAS,CAAC;AACvC,SAAO,GAAG,MAAM,KAAK,YAAY,OAAO,QAAQ;AAClD;
|
|
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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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const DECIMAL_PATTERN = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
|
|
2
|
+
const EXACT_DECIMAL_ZERO = { units: 0n, scale: 0 };
|
|
3
|
+
function pow10(exponent) {
|
|
4
|
+
return 10n ** BigInt(exponent);
|
|
5
|
+
}
|
|
6
|
+
function parseExactDecimal(raw) {
|
|
7
|
+
if (raw === null || raw === void 0) return null;
|
|
8
|
+
if (typeof raw === "bigint") return { units: raw, scale: 0 };
|
|
9
|
+
let text;
|
|
10
|
+
if (typeof raw === "number") {
|
|
11
|
+
if (!Number.isFinite(raw)) return null;
|
|
12
|
+
text = String(raw);
|
|
13
|
+
} else if (typeof raw === "string") {
|
|
14
|
+
text = raw.trim();
|
|
15
|
+
} else {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!text) return null;
|
|
19
|
+
const match = DECIMAL_PATTERN.exec(text);
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
const [, sign, intPart = "", fracPart = "", exponentPart] = match;
|
|
22
|
+
if (!intPart && !fracPart) return null;
|
|
23
|
+
const digits = `${intPart}${fracPart}`;
|
|
24
|
+
let units = BigInt(digits);
|
|
25
|
+
let scale = fracPart.length;
|
|
26
|
+
if (exponentPart) {
|
|
27
|
+
scale -= Number(exponentPart);
|
|
28
|
+
if (scale < 0) {
|
|
29
|
+
units *= pow10(-scale);
|
|
30
|
+
scale = 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (sign === "-") units = -units;
|
|
34
|
+
return { units, scale };
|
|
35
|
+
}
|
|
36
|
+
function rescale(value, scale) {
|
|
37
|
+
return scale === value.scale ? value.units : value.units * pow10(scale - value.scale);
|
|
38
|
+
}
|
|
39
|
+
function addExactDecimal(left, right) {
|
|
40
|
+
const scale = Math.max(left.scale, right.scale);
|
|
41
|
+
return { units: rescale(left, scale) + rescale(right, scale), scale };
|
|
42
|
+
}
|
|
43
|
+
function compareExactDecimal(left, right) {
|
|
44
|
+
const scale = Math.max(left.scale, right.scale);
|
|
45
|
+
const leftUnits = rescale(left, scale);
|
|
46
|
+
const rightUnits = rescale(right, scale);
|
|
47
|
+
if (leftUnits === rightUnits) return 0;
|
|
48
|
+
return leftUnits < rightUnits ? -1 : 1;
|
|
49
|
+
}
|
|
50
|
+
function exactDecimalToString(value) {
|
|
51
|
+
const negative = value.units < 0n;
|
|
52
|
+
const digits = (negative ? -value.units : value.units).toString().padStart(value.scale + 1, "0");
|
|
53
|
+
const boundary = digits.length - value.scale;
|
|
54
|
+
const integerPart = digits.slice(0, boundary);
|
|
55
|
+
const fractionPart = value.scale > 0 ? digits.slice(boundary) : "";
|
|
56
|
+
return `${negative ? "-" : ""}${integerPart}${fractionPart ? `.${fractionPart}` : ""}`;
|
|
57
|
+
}
|
|
58
|
+
function exactDecimalToNumber(value) {
|
|
59
|
+
return Number(exactDecimalToString(value));
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
EXACT_DECIMAL_ZERO,
|
|
63
|
+
addExactDecimal,
|
|
64
|
+
compareExactDecimal,
|
|
65
|
+
exactDecimalToNumber,
|
|
66
|
+
exactDecimalToString,
|
|
67
|
+
parseExactDecimal
|
|
68
|
+
};
|
|
69
|
+
//# sourceMappingURL=exactDecimal.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/dashboards/lib/exactDecimal.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Minimal exact decimal arithmetic for application-side aggregation of PostgreSQL `numeric`\n * columns. The SQL path sums money in `numeric` and converts once, so the application path must\n * not accumulate in binary floating point \u2014 `0.1 + 0.2` would drift the totals of encrypted group\n * sources away from the plaintext ones (#4622).\n *\n * A value is stored as scaled integer `units` plus a decimal `scale`, so `12.34` is\n * `{ units: 1234n, scale: 2 }`. Conversion to `number` happens only at the response boundary.\n */\n\nexport type ExactDecimal = {\n units: bigint\n scale: number\n}\n\nconst DECIMAL_PATTERN = /^([+-]?)(\\d*)(?:\\.(\\d*))?(?:[eE]([+-]?\\d+))?$/\n\nexport const EXACT_DECIMAL_ZERO: ExactDecimal = { units: 0n, scale: 0 }\n\nfunction pow10(exponent: number): bigint {\n return 10n ** BigInt(exponent)\n}\n\n/**\n * Parses a `numeric` column value. The PostgreSQL driver hands these over as strings to preserve\n * precision, so the string form is authoritative; numbers and bigints are accepted for callers that\n * already hold a JavaScript value. Returns `null` for anything that is not a finite decimal.\n */\nexport function parseExactDecimal(raw: unknown): ExactDecimal | null {\n if (raw === null || raw === undefined) return null\n if (typeof raw === 'bigint') return { units: raw, scale: 0 }\n\n let text: string\n if (typeof raw === 'number') {\n if (!Number.isFinite(raw)) return null\n text = String(raw)\n } else if (typeof raw === 'string') {\n text = raw.trim()\n } else {\n return null\n }\n\n if (!text) return null\n\n const match = DECIMAL_PATTERN.exec(text)\n if (!match) return null\n\n const [, sign, intPart = '', fracPart = '', exponentPart] = match\n if (!intPart && !fracPart) return null\n\n const digits = `${intPart}${fracPart}`\n let units = BigInt(digits)\n let scale = fracPart.length\n\n if (exponentPart) {\n scale -= Number(exponentPart)\n if (scale < 0) {\n units *= pow10(-scale)\n scale = 0\n }\n }\n\n if (sign === '-') units = -units\n return { units, scale }\n}\n\nfunction rescale(value: ExactDecimal, scale: number): bigint {\n return scale === value.scale ? value.units : value.units * pow10(scale - value.scale)\n}\n\nexport function addExactDecimal(left: ExactDecimal, right: ExactDecimal): ExactDecimal {\n const scale = Math.max(left.scale, right.scale)\n return { units: rescale(left, scale) + rescale(right, scale), scale }\n}\n\nexport function compareExactDecimal(left: ExactDecimal, right: ExactDecimal): number {\n const scale = Math.max(left.scale, right.scale)\n const leftUnits = rescale(left, scale)\n const rightUnits = rescale(right, scale)\n if (leftUnits === rightUnits) return 0\n return leftUnits < rightUnits ? -1 : 1\n}\n\nexport function exactDecimalToString(value: ExactDecimal): string {\n const negative = value.units < 0n\n const digits = (negative ? -value.units : value.units).toString().padStart(value.scale + 1, '0')\n const boundary = digits.length - value.scale\n const integerPart = digits.slice(0, boundary)\n const fractionPart = value.scale > 0 ? digits.slice(boundary) : ''\n return `${negative ? '-' : ''}${integerPart}${fractionPart ? `.${fractionPart}` : ''}`\n}\n\n/** Response-boundary conversion: the only place where the exact value becomes a binary float. */\nexport function exactDecimalToNumber(value: ExactDecimal): number {\n return Number(exactDecimalToString(value))\n}\n"],
|
|
5
|
+
"mappings": "AAeA,MAAM,kBAAkB;AAEjB,MAAM,qBAAmC,EAAE,OAAO,IAAI,OAAO,EAAE;AAEtE,SAAS,MAAM,UAA0B;AACvC,SAAO,OAAO,OAAO,QAAQ;AAC/B;AAOO,SAAS,kBAAkB,KAAmC;AACnE,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAC9C,MAAI,OAAO,QAAQ,SAAU,QAAO,EAAE,OAAO,KAAK,OAAO,EAAE;AAE3D,MAAI;AACJ,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,WAAO,OAAO,GAAG;AAAA,EACnB,WAAW,OAAO,QAAQ,UAAU;AAClC,WAAO,IAAI,KAAK;AAAA,EAClB,OAAO;AACL,WAAO;AAAA,EACT;AAEA,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,QAAQ,gBAAgB,KAAK,IAAI;AACvC,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,CAAC,EAAE,MAAM,UAAU,IAAI,WAAW,IAAI,YAAY,IAAI;AAC5D,MAAI,CAAC,WAAW,CAAC,SAAU,QAAO;AAElC,QAAM,SAAS,GAAG,OAAO,GAAG,QAAQ;AACpC,MAAI,QAAQ,OAAO,MAAM;AACzB,MAAI,QAAQ,SAAS;AAErB,MAAI,cAAc;AAChB,aAAS,OAAO,YAAY;AAC5B,QAAI,QAAQ,GAAG;AACb,eAAS,MAAM,CAAC,KAAK;AACrB,cAAQ;AAAA,IACV;AAAA,EACF;AAEA,MAAI,SAAS,IAAK,SAAQ,CAAC;AAC3B,SAAO,EAAE,OAAO,MAAM;AACxB;AAEA,SAAS,QAAQ,OAAqB,OAAuB;AAC3D,SAAO,UAAU,MAAM,QAAQ,MAAM,QAAQ,MAAM,QAAQ,MAAM,QAAQ,MAAM,KAAK;AACtF;AAEO,SAAS,gBAAgB,MAAoB,OAAmC;AACrF,QAAM,QAAQ,KAAK,IAAI,KAAK,OAAO,MAAM,KAAK;AAC9C,SAAO,EAAE,OAAO,QAAQ,MAAM,KAAK,IAAI,QAAQ,OAAO,KAAK,GAAG,MAAM;AACtE;AAEO,SAAS,oBAAoB,MAAoB,OAA6B;AACnF,QAAM,QAAQ,KAAK,IAAI,KAAK,OAAO,MAAM,KAAK;AAC9C,QAAM,YAAY,QAAQ,MAAM,KAAK;AACrC,QAAM,aAAa,QAAQ,OAAO,KAAK;AACvC,MAAI,cAAc,WAAY,QAAO;AACrC,SAAO,YAAY,aAAa,KAAK;AACvC;AAEO,SAAS,qBAAqB,OAA6B;AAChE,QAAM,WAAW,MAAM,QAAQ;AAC/B,QAAM,UAAU,WAAW,CAAC,MAAM,QAAQ,MAAM,OAAO,SAAS,EAAE,SAAS,MAAM,QAAQ,GAAG,GAAG;AAC/F,QAAM,WAAW,OAAO,SAAS,MAAM;AACvC,QAAM,cAAc,OAAO,MAAM,GAAG,QAAQ;AAC5C,QAAM,eAAe,MAAM,QAAQ,IAAI,OAAO,MAAM,QAAQ,IAAI;AAChE,SAAO,GAAG,WAAW,MAAM,EAAE,GAAG,WAAW,GAAG,eAAe,IAAI,YAAY,KAAK,EAAE;AACtF;AAGO,SAAS,qBAAqB,OAA6B;AAChE,SAAO,OAAO,qBAAqB,KAAK,CAAC;AAC3C;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|