@open-mercato/core 0.6.4-develop.4121.1.0d7f20d229 → 0.6.4-develop.4152.1.1c429e5200
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/customer_accounts/api/admin/users/[id]/reset-password.js +1 -0
- package/dist/modules/customer_accounts/api/admin/users/[id]/reset-password.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users/[id]/send-reset-link.js +1 -0
- package/dist/modules/customer_accounts/api/admin/users/[id]/send-reset-link.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users/[id]/verify-email.js +1 -0
- package/dist/modules/customer_accounts/api/admin/users/[id]/verify-email.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users/[id].js +1 -0
- package/dist/modules/customer_accounts/api/admin/users/[id].js.map +2 -2
- package/dist/modules/customer_accounts/api/email/verify.js +1 -0
- package/dist/modules/customer_accounts/api/email/verify.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/events/stream.js +20 -2
- package/dist/modules/customer_accounts/api/portal/events/stream.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +137 -0
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +7 -0
- package/dist/modules/dashboards/api/widgets/data/route.js +1 -75
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/schema.js +85 -0
- package/dist/modules/dashboards/api/widgets/data/schema.js.map +7 -0
- package/dist/modules/dashboards/lib/widgetDataBatch.js +49 -0
- package/dist/modules/dashboards/lib/widgetDataBatch.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js +6 -14
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js.map +2 -2
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +6 -14
- 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 +6 -14
- 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 +6 -14
- 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 +6 -14
- 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 +6 -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 +6 -14
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +19 -13
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/dist/modules/planner/components/availabilityRulesEditorState.js +10 -1
- package/dist/modules/planner/components/availabilityRulesEditorState.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customer_accounts/api/admin/users/[id]/reset-password.ts +1 -0
- package/src/modules/customer_accounts/api/admin/users/[id]/send-reset-link.ts +1 -0
- package/src/modules/customer_accounts/api/admin/users/[id]/verify-email.ts +1 -0
- package/src/modules/customer_accounts/api/admin/users/[id].ts +1 -0
- package/src/modules/customer_accounts/api/email/verify.ts +1 -0
- package/src/modules/customer_accounts/api/portal/events/stream.ts +23 -2
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +168 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +1 -90
- package/src/modules/dashboards/api/widgets/data/schema.ts +90 -0
- package/src/modules/dashboards/lib/widgetDataBatch.ts +89 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +6 -16
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +6 -16
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +20 -13
- package/src/modules/planner/components/availabilityRulesEditorState.ts +21 -0
- package/src/modules/planner/i18n/de.json +3 -3
- package/src/modules/planner/i18n/en.json +3 -3
- package/src/modules/planner/i18n/es.json +3 -3
- package/src/modules/planner/i18n/pl.json +3 -3
|
@@ -2,7 +2,16 @@ function resolveRuleSetSelectValue(ruleSets, selectedRulesetId) {
|
|
|
2
2
|
if (!selectedRulesetId) return void 0;
|
|
3
3
|
return ruleSets.some((ruleSet) => ruleSet.id === selectedRulesetId) ? selectedRulesetId : void 0;
|
|
4
4
|
}
|
|
5
|
+
function selectCustomRuleIdsToDelete(transition, rules) {
|
|
6
|
+
if (transition === "switch") return [];
|
|
7
|
+
return Array.from(new Set(rules.map((rule) => rule.id)));
|
|
8
|
+
}
|
|
9
|
+
function requiresResetConfirmation(rules) {
|
|
10
|
+
return rules.length > 0;
|
|
11
|
+
}
|
|
5
12
|
export {
|
|
6
|
-
|
|
13
|
+
requiresResetConfirmation,
|
|
14
|
+
resolveRuleSetSelectValue,
|
|
15
|
+
selectCustomRuleIdsToDelete
|
|
7
16
|
};
|
|
8
17
|
//# sourceMappingURL=availabilityRulesEditorState.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/planner/components/availabilityRulesEditorState.ts"],
|
|
4
|
-
"sourcesContent": ["export type AvailabilityRuleSetOption = {\n id: string\n}\n\nexport function resolveRuleSetSelectValue(\n ruleSets: AvailabilityRuleSetOption[],\n selectedRulesetId: string | null | undefined,\n): string | undefined {\n if (!selectedRulesetId) return undefined\n return ruleSets.some((ruleSet) => ruleSet.id === selectedRulesetId) ? selectedRulesetId : undefined\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["export type AvailabilityRuleSetOption = {\n id: string\n}\n\nexport type AvailabilityRuleRef = {\n id: string\n}\n\nexport type RuleSetTransition = 'switch' | 'reset'\n\nexport function resolveRuleSetSelectValue(\n ruleSets: AvailabilityRuleSetOption[],\n selectedRulesetId: string | null | undefined,\n): string | undefined {\n if (!selectedRulesetId) return undefined\n return ruleSets.some((ruleSet) => ruleSet.id === selectedRulesetId) ? selectedRulesetId : undefined\n}\n\n// Selects which member-level custom rules to delete for a ruleset transition.\n// Switching schedules preserves the member's saved custom hours (#2325): only\n// an explicit \"Reset to schedule\" discards them so the shared schedule applies.\nexport function selectCustomRuleIdsToDelete(\n transition: RuleSetTransition,\n rules: AvailabilityRuleRef[],\n): string[] {\n if (transition === 'switch') return []\n return Array.from(new Set(rules.map((rule) => rule.id)))\n}\n\nexport function requiresResetConfirmation(rules: AvailabilityRuleRef[]): boolean {\n return rules.length > 0\n}\n"],
|
|
5
|
+
"mappings": "AAUO,SAAS,0BACd,UACA,mBACoB;AACpB,MAAI,CAAC,kBAAmB,QAAO;AAC/B,SAAO,SAAS,KAAK,CAAC,YAAY,QAAQ,OAAO,iBAAiB,IAAI,oBAAoB;AAC5F;AAKO,SAAS,4BACd,YACA,OACU;AACV,MAAI,eAAe,SAAU,QAAO,CAAC;AACrC,SAAO,MAAM,KAAK,IAAI,IAAI,MAAM,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC,CAAC;AACzD;AAEO,SAAS,0BAA0B,OAAuC;AAC/E,SAAO,MAAM,SAAS;AACxB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4152.1.1c429e5200",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -243,16 +243,16 @@
|
|
|
243
243
|
"zod": "^4.4.3"
|
|
244
244
|
},
|
|
245
245
|
"peerDependencies": {
|
|
246
|
-
"@open-mercato/ai-assistant": "0.6.4-develop.
|
|
247
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
248
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
246
|
+
"@open-mercato/ai-assistant": "0.6.4-develop.4152.1.1c429e5200",
|
|
247
|
+
"@open-mercato/shared": "0.6.4-develop.4152.1.1c429e5200",
|
|
248
|
+
"@open-mercato/ui": "0.6.4-develop.4152.1.1c429e5200",
|
|
249
249
|
"react": "^19.0.0",
|
|
250
250
|
"react-dom": "^19.0.0"
|
|
251
251
|
},
|
|
252
252
|
"devDependencies": {
|
|
253
|
-
"@open-mercato/ai-assistant": "0.6.4-develop.
|
|
254
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
255
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
253
|
+
"@open-mercato/ai-assistant": "0.6.4-develop.4152.1.1c429e5200",
|
|
254
|
+
"@open-mercato/shared": "0.6.4-develop.4152.1.1c429e5200",
|
|
255
|
+
"@open-mercato/ui": "0.6.4-develop.4152.1.1c429e5200",
|
|
256
256
|
"@testing-library/dom": "^10.4.1",
|
|
257
257
|
"@testing-library/jest-dom": "^6.9.1",
|
|
258
258
|
"@testing-library/react": "^16.3.1",
|
|
@@ -52,6 +52,7 @@ export async function POST(req: Request, { params }: { params: { id: string } })
|
|
|
52
52
|
|
|
53
53
|
void emitCustomerAccountsEvent('customer_accounts.password.reset', {
|
|
54
54
|
id: user.id,
|
|
55
|
+
recipientUserId: user.id,
|
|
55
56
|
email: user.email,
|
|
56
57
|
tenantId: auth.tenantId,
|
|
57
58
|
organizationId: auth.orgId,
|
|
@@ -36,6 +36,7 @@ export async function POST(req: Request, { params }: { params: { id: string } })
|
|
|
36
36
|
|
|
37
37
|
void emitCustomerAccountsEvent('customer_accounts.password.reset', {
|
|
38
38
|
id: user.id,
|
|
39
|
+
recipientUserId: user.id,
|
|
39
40
|
email: user.email,
|
|
40
41
|
tenantId: auth.tenantId,
|
|
41
42
|
organizationId: auth.orgId,
|
|
@@ -41,6 +41,7 @@ export async function POST(req: Request, { params }: { params: { id: string } })
|
|
|
41
41
|
|
|
42
42
|
void emitCustomerAccountsEvent('customer_accounts.email.verified', {
|
|
43
43
|
id: user.id,
|
|
44
|
+
recipientUserId: user.id,
|
|
44
45
|
email: user.email,
|
|
45
46
|
tenantId: auth.tenantId,
|
|
46
47
|
organizationId: auth.orgId,
|
|
@@ -190,6 +190,7 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|
|
190
190
|
|
|
191
191
|
void emitCustomerAccountsEvent('customer_accounts.user.updated', {
|
|
192
192
|
id: user.id,
|
|
193
|
+
recipientUserId: user.id,
|
|
193
194
|
email: user.email,
|
|
194
195
|
tenantId: auth.tenantId,
|
|
195
196
|
organizationId: auth.orgId,
|
|
@@ -31,6 +31,7 @@ type PortalSseConnection = {
|
|
|
31
31
|
function normalizeAudience(data: Record<string, unknown>): {
|
|
32
32
|
tenantId: string | null
|
|
33
33
|
organizationScopes: string[]
|
|
34
|
+
recipientUserScopes: string[]
|
|
34
35
|
} {
|
|
35
36
|
const tenantId = typeof data.tenantId === 'string' ? data.tenantId : null
|
|
36
37
|
const organizationScopes = new Set<string>()
|
|
@@ -44,7 +45,24 @@ function normalizeAudience(data: Record<string, unknown>): {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
const recipientUserScopes = new Set<string>()
|
|
50
|
+
if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {
|
|
51
|
+
recipientUserScopes.add(data.recipientUserId.trim())
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(data.recipientUserIds)) {
|
|
54
|
+
for (const userId of data.recipientUserIds) {
|
|
55
|
+
if (typeof userId === 'string' && userId.trim().length > 0) {
|
|
56
|
+
recipientUserScopes.add(userId.trim())
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
tenantId,
|
|
63
|
+
organizationScopes: Array.from(organizationScopes),
|
|
64
|
+
recipientUserScopes: Array.from(recipientUserScopes),
|
|
65
|
+
}
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
function matchesAudience(conn: PortalSseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {
|
|
@@ -53,6 +71,9 @@ function matchesAudience(conn: PortalSseConnection, audience: ReturnType<typeof
|
|
|
53
71
|
if (audience.organizationScopes.length > 0) {
|
|
54
72
|
if (!audience.organizationScopes.includes(conn.organizationId)) return false
|
|
55
73
|
}
|
|
74
|
+
if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.customerUserId)) {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
56
77
|
return true
|
|
57
78
|
}
|
|
58
79
|
|
|
@@ -194,7 +215,7 @@ export async function GET(req: Request): Promise<Response> {
|
|
|
194
215
|
|
|
195
216
|
const methodDoc: OpenApiMethodDoc = {
|
|
196
217
|
summary: 'Subscribe to portal events via SSE (Portal Event Bridge)',
|
|
197
|
-
description: 'Long-lived SSE connection that receives server-side events marked with portalBroadcast: true. Events are filtered by the customer\'s tenant and
|
|
218
|
+
description: 'Long-lived SSE connection that receives server-side events marked with portalBroadcast: true. Events are filtered by the customer\'s tenant, organization, and recipient user audience.',
|
|
198
219
|
tags: ['Customer Portal'],
|
|
199
220
|
responses: [
|
|
200
221
|
{
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { CacheStrategy } from '@open-mercato/cache'
|
|
5
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
|
+
import {
|
|
9
|
+
createWidgetDataService,
|
|
10
|
+
type WidgetDataRequest,
|
|
11
|
+
WidgetDataValidationError,
|
|
12
|
+
} from '../../../../services/widgetDataService'
|
|
13
|
+
import { runWidgetDataBatch } from '../../../../lib/widgetDataBatch'
|
|
14
|
+
import type { AnalyticsRegistry } from '../../../../services/analyticsRegistry'
|
|
15
|
+
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
16
|
+
import { dashboardsTag, dashboardsErrorSchema } from '../../../openapi'
|
|
17
|
+
import { widgetDataRequestSchema, widgetDataResponseSchema } from '../schema'
|
|
18
|
+
|
|
19
|
+
export const metadata = {
|
|
20
|
+
POST: { requireAuth: true, requireFeatures: ['analytics.view'] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAX_BATCH_SIZE = 50
|
|
24
|
+
|
|
25
|
+
const widgetDataBatchRequestSchema = z.object({
|
|
26
|
+
requests: z
|
|
27
|
+
.array(
|
|
28
|
+
z.object({
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
request: widgetDataRequestSchema,
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(MAX_BATCH_SIZE),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const widgetDataBatchResponseSchema = z.object({
|
|
38
|
+
results: z.array(
|
|
39
|
+
z.discriminatedUnion('ok', [
|
|
40
|
+
z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
ok: z.literal(true),
|
|
43
|
+
data: widgetDataResponseSchema,
|
|
44
|
+
}),
|
|
45
|
+
z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
ok: z.literal(false),
|
|
48
|
+
error: z.string(),
|
|
49
|
+
}),
|
|
50
|
+
]),
|
|
51
|
+
),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export async function POST(req: Request) {
|
|
55
|
+
const auth = await getAuthFromRequest(req)
|
|
56
|
+
if (!auth) {
|
|
57
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let body: unknown
|
|
61
|
+
try {
|
|
62
|
+
body = await req.json()
|
|
63
|
+
} catch {
|
|
64
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parsed = widgetDataBatchRequestSchema.safeParse(body)
|
|
68
|
+
if (!parsed.success) {
|
|
69
|
+
return NextResponse.json(
|
|
70
|
+
{ error: 'Invalid request payload', issues: parsed.error.issues },
|
|
71
|
+
{ status: 400 },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tenantId = auth.tenantId ?? null
|
|
76
|
+
if (!tenantId) {
|
|
77
|
+
return NextResponse.json({ error: 'Tenant context is required' }, { status: 400 })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build the per-request DI/RBAC/org-scope stack exactly once for the whole
|
|
81
|
+
// batch instead of once per widget (see issue #2273).
|
|
82
|
+
const container = await createRequestContainer()
|
|
83
|
+
const analyticsRegistry = container.resolve<AnalyticsRegistry>('analyticsRegistry')
|
|
84
|
+
|
|
85
|
+
const em = (container.resolve('em') as EntityManager).fork({
|
|
86
|
+
clear: true,
|
|
87
|
+
freshEventManager: true,
|
|
88
|
+
useContext: true,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
92
|
+
|
|
93
|
+
const organizationIds = (() => {
|
|
94
|
+
if (scope?.selectedId) return [scope.selectedId]
|
|
95
|
+
if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds
|
|
96
|
+
if (scope?.allowedIds === null) return undefined
|
|
97
|
+
if (auth.orgId) return [auth.orgId]
|
|
98
|
+
return undefined
|
|
99
|
+
})()
|
|
100
|
+
|
|
101
|
+
const cache = container.resolve<CacheStrategy>('cache')
|
|
102
|
+
const service = createWidgetDataService(em, { tenantId, organizationIds }, analyticsRegistry, cache)
|
|
103
|
+
|
|
104
|
+
const rbacService = container.resolve<{
|
|
105
|
+
userHasAllFeatures: (
|
|
106
|
+
userId: string,
|
|
107
|
+
features: string[],
|
|
108
|
+
scope: { tenantId: string; organizationId?: string | null },
|
|
109
|
+
) => Promise<boolean>
|
|
110
|
+
}>('rbacService')
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const results = await runWidgetDataBatch(parsed.data.requests as Array<{ id: string; request: WidgetDataRequest }>, {
|
|
114
|
+
getRequiredFeatures: (entityType) => analyticsRegistry.getRequiredFeatures(entityType),
|
|
115
|
+
checkFeatures: (features) => {
|
|
116
|
+
if (features.length === 0) return Promise.resolve(true)
|
|
117
|
+
return rbacService.userHasAllFeatures(auth.sub, features, {
|
|
118
|
+
tenantId,
|
|
119
|
+
organizationId: auth.orgId,
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
fetchOne: (request) => service.fetchWidgetData(request),
|
|
123
|
+
describeError: (error) =>
|
|
124
|
+
error instanceof WidgetDataValidationError
|
|
125
|
+
? error.message
|
|
126
|
+
: 'An error occurred while processing your request',
|
|
127
|
+
})
|
|
128
|
+
return NextResponse.json({ results })
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('[widgets/data/batch] Error:', err)
|
|
131
|
+
return NextResponse.json(
|
|
132
|
+
{ error: 'An error occurred while processing your request' },
|
|
133
|
+
{ status: 500 },
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const widgetDataBatchPostDoc: OpenApiMethodDoc = {
|
|
139
|
+
summary: 'Fetch aggregated data for multiple dashboard widgets in one request',
|
|
140
|
+
description:
|
|
141
|
+
'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.',
|
|
142
|
+
tags: [dashboardsTag],
|
|
143
|
+
requestBody: {
|
|
144
|
+
contentType: 'application/json',
|
|
145
|
+
schema: widgetDataBatchRequestSchema,
|
|
146
|
+
description: 'A list of id-keyed widget data requests to resolve together.',
|
|
147
|
+
},
|
|
148
|
+
responses: [
|
|
149
|
+
{
|
|
150
|
+
status: 200,
|
|
151
|
+
description: 'Per-widget aggregation results keyed by request id.',
|
|
152
|
+
schema: widgetDataBatchResponseSchema,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
errors: [
|
|
156
|
+
{ status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },
|
|
157
|
+
{ status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },
|
|
158
|
+
{ status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const openApi: OpenApiRouteDoc = {
|
|
163
|
+
tag: dashboardsTag,
|
|
164
|
+
summary: 'Batch widget data aggregation endpoint',
|
|
165
|
+
methods: {
|
|
166
|
+
POST: widgetDataBatchPostDoc,
|
|
167
|
+
},
|
|
168
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { z } from 'zod'
|
|
3
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
3
|
import type { CacheStrategy } from '@open-mercato/cache'
|
|
5
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
@@ -13,100 +12,12 @@ import {
|
|
|
13
12
|
import type { AnalyticsRegistry } from '../../../services/analyticsRegistry'
|
|
14
13
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
15
14
|
import { dashboardsTag, dashboardsErrorSchema } from '../../openapi'
|
|
15
|
+
import { widgetDataRequestSchema, widgetDataResponseSchema } from './schema'
|
|
16
16
|
|
|
17
17
|
export const metadata = {
|
|
18
18
|
POST: { requireAuth: true, requireFeatures: ['analytics.view'] },
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])
|
|
22
|
-
const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])
|
|
23
|
-
const dateRangePresetSchema = z.enum([
|
|
24
|
-
'today',
|
|
25
|
-
'yesterday',
|
|
26
|
-
'this_week',
|
|
27
|
-
'last_week',
|
|
28
|
-
'this_month',
|
|
29
|
-
'last_month',
|
|
30
|
-
'this_quarter',
|
|
31
|
-
'last_quarter',
|
|
32
|
-
'this_year',
|
|
33
|
-
'last_year',
|
|
34
|
-
'last_7_days',
|
|
35
|
-
'last_30_days',
|
|
36
|
-
'last_90_days',
|
|
37
|
-
])
|
|
38
|
-
|
|
39
|
-
const filterOperatorSchema = z.enum([
|
|
40
|
-
'eq',
|
|
41
|
-
'neq',
|
|
42
|
-
'gt',
|
|
43
|
-
'gte',
|
|
44
|
-
'lt',
|
|
45
|
-
'lte',
|
|
46
|
-
'in',
|
|
47
|
-
'not_in',
|
|
48
|
-
'is_null',
|
|
49
|
-
'is_not_null',
|
|
50
|
-
])
|
|
51
|
-
|
|
52
|
-
const widgetDataRequestSchema = z.object({
|
|
53
|
-
entityType: z.string().min(1),
|
|
54
|
-
metric: z.object({
|
|
55
|
-
field: z.string().min(1),
|
|
56
|
-
aggregate: aggregateFunctionSchema,
|
|
57
|
-
}),
|
|
58
|
-
groupBy: z
|
|
59
|
-
.object({
|
|
60
|
-
field: z.string().min(1),
|
|
61
|
-
granularity: dateGranularitySchema.optional(),
|
|
62
|
-
limit: z.number().int().min(1).max(100).optional(),
|
|
63
|
-
resolveLabels: z.boolean().optional(),
|
|
64
|
-
})
|
|
65
|
-
.optional(),
|
|
66
|
-
filters: z
|
|
67
|
-
.array(
|
|
68
|
-
z.object({
|
|
69
|
-
field: z.string().min(1),
|
|
70
|
-
operator: filterOperatorSchema,
|
|
71
|
-
value: z.unknown().optional(),
|
|
72
|
-
}),
|
|
73
|
-
)
|
|
74
|
-
.optional(),
|
|
75
|
-
dateRange: z
|
|
76
|
-
.object({
|
|
77
|
-
field: z.string().min(1),
|
|
78
|
-
preset: dateRangePresetSchema,
|
|
79
|
-
})
|
|
80
|
-
.optional(),
|
|
81
|
-
comparison: z
|
|
82
|
-
.object({
|
|
83
|
-
type: z.enum(['previous_period', 'previous_year']),
|
|
84
|
-
})
|
|
85
|
-
.optional(),
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
const widgetDataItemSchema = z.object({
|
|
89
|
-
groupKey: z.unknown(),
|
|
90
|
-
groupLabel: z.string().optional(),
|
|
91
|
-
value: z.number().nullable(),
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
const widgetDataResponseSchema = z.object({
|
|
95
|
-
value: z.number().nullable(),
|
|
96
|
-
data: z.array(widgetDataItemSchema),
|
|
97
|
-
comparison: z
|
|
98
|
-
.object({
|
|
99
|
-
value: z.number().nullable(),
|
|
100
|
-
change: z.number(),
|
|
101
|
-
direction: z.enum(['up', 'down', 'unchanged']),
|
|
102
|
-
})
|
|
103
|
-
.optional(),
|
|
104
|
-
metadata: z.object({
|
|
105
|
-
fetchedAt: z.string(),
|
|
106
|
-
recordCount: z.number(),
|
|
107
|
-
}),
|
|
108
|
-
})
|
|
109
|
-
|
|
110
21
|
export async function POST(req: Request) {
|
|
111
22
|
const auth = await getAuthFromRequest(req)
|
|
112
23
|
if (!auth) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])
|
|
4
|
+
export const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])
|
|
5
|
+
export const dateRangePresetSchema = z.enum([
|
|
6
|
+
'today',
|
|
7
|
+
'yesterday',
|
|
8
|
+
'this_week',
|
|
9
|
+
'last_week',
|
|
10
|
+
'this_month',
|
|
11
|
+
'last_month',
|
|
12
|
+
'this_quarter',
|
|
13
|
+
'last_quarter',
|
|
14
|
+
'this_year',
|
|
15
|
+
'last_year',
|
|
16
|
+
'last_7_days',
|
|
17
|
+
'last_30_days',
|
|
18
|
+
'last_90_days',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
export const filterOperatorSchema = z.enum([
|
|
22
|
+
'eq',
|
|
23
|
+
'neq',
|
|
24
|
+
'gt',
|
|
25
|
+
'gte',
|
|
26
|
+
'lt',
|
|
27
|
+
'lte',
|
|
28
|
+
'in',
|
|
29
|
+
'not_in',
|
|
30
|
+
'is_null',
|
|
31
|
+
'is_not_null',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
export const widgetDataRequestSchema = z.object({
|
|
35
|
+
entityType: z.string().min(1),
|
|
36
|
+
metric: z.object({
|
|
37
|
+
field: z.string().min(1),
|
|
38
|
+
aggregate: aggregateFunctionSchema,
|
|
39
|
+
}),
|
|
40
|
+
groupBy: z
|
|
41
|
+
.object({
|
|
42
|
+
field: z.string().min(1),
|
|
43
|
+
granularity: dateGranularitySchema.optional(),
|
|
44
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
45
|
+
resolveLabels: z.boolean().optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
filters: z
|
|
49
|
+
.array(
|
|
50
|
+
z.object({
|
|
51
|
+
field: z.string().min(1),
|
|
52
|
+
operator: filterOperatorSchema,
|
|
53
|
+
value: z.unknown().optional(),
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
.optional(),
|
|
57
|
+
dateRange: z
|
|
58
|
+
.object({
|
|
59
|
+
field: z.string().min(1),
|
|
60
|
+
preset: dateRangePresetSchema,
|
|
61
|
+
})
|
|
62
|
+
.optional(),
|
|
63
|
+
comparison: z
|
|
64
|
+
.object({
|
|
65
|
+
type: z.enum(['previous_period', 'previous_year']),
|
|
66
|
+
})
|
|
67
|
+
.optional(),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export const widgetDataItemSchema = z.object({
|
|
71
|
+
groupKey: z.unknown(),
|
|
72
|
+
groupLabel: z.string().optional(),
|
|
73
|
+
value: z.number().nullable(),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const widgetDataResponseSchema = z.object({
|
|
77
|
+
value: z.number().nullable(),
|
|
78
|
+
data: z.array(widgetDataItemSchema),
|
|
79
|
+
comparison: z
|
|
80
|
+
.object({
|
|
81
|
+
value: z.number().nullable(),
|
|
82
|
+
change: z.number(),
|
|
83
|
+
direction: z.enum(['up', 'down', 'unchanged']),
|
|
84
|
+
})
|
|
85
|
+
.optional(),
|
|
86
|
+
metadata: z.object({
|
|
87
|
+
fetchedAt: z.string(),
|
|
88
|
+
recordCount: z.number(),
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { WidgetDataRequest, WidgetDataResponse } from '../services/widgetDataService'
|
|
2
|
+
|
|
3
|
+
export type WidgetDataBatchEntry = {
|
|
4
|
+
id: string
|
|
5
|
+
request: WidgetDataRequest
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type WidgetDataBatchResult =
|
|
9
|
+
| { id: string; ok: true; data: WidgetDataResponse }
|
|
10
|
+
| { id: string; ok: false; error: string }
|
|
11
|
+
|
|
12
|
+
export type WidgetDataBatchDeps = {
|
|
13
|
+
getRequiredFeatures: (entityType: string) => string[] | null
|
|
14
|
+
checkFeatures: (features: string[]) => Promise<boolean>
|
|
15
|
+
fetchOne: (request: WidgetDataRequest) => Promise<WidgetDataResponse>
|
|
16
|
+
describeError: (error: unknown) => string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolves per-entity-type feature access for a batch of widget requests while
|
|
21
|
+
* collapsing the common case to a single RBAC resolution. The happy path checks
|
|
22
|
+
* the union of all required features once; only when the union check fails do we
|
|
23
|
+
* fall back to per-entity-type checks so a single privileged entity type does
|
|
24
|
+
* not reject widgets the caller is allowed to see.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveEntityFeatureAccess(
|
|
27
|
+
entityTypes: string[],
|
|
28
|
+
getRequiredFeatures: (entityType: string) => string[] | null,
|
|
29
|
+
checkFeatures: (features: string[]) => Promise<boolean>,
|
|
30
|
+
): Promise<Map<string, boolean>> {
|
|
31
|
+
const access = new Map<string, boolean>()
|
|
32
|
+
const featuresByEntity = new Map<string, string[]>()
|
|
33
|
+
const unionFeatures = new Set<string>()
|
|
34
|
+
|
|
35
|
+
for (const entityType of new Set(entityTypes)) {
|
|
36
|
+
const features = getRequiredFeatures(entityType) ?? []
|
|
37
|
+
featuresByEntity.set(entityType, features)
|
|
38
|
+
if (features.length === 0) {
|
|
39
|
+
access.set(entityType, true)
|
|
40
|
+
} else {
|
|
41
|
+
for (const feature of features) unionFeatures.add(feature)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const gated = [...featuresByEntity.entries()].filter(([, features]) => features.length > 0)
|
|
46
|
+
if (gated.length === 0) return access
|
|
47
|
+
|
|
48
|
+
if (await checkFeatures([...unionFeatures])) {
|
|
49
|
+
for (const [entityType] of gated) access.set(entityType, true)
|
|
50
|
+
return access
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [entityType, features] of gated) {
|
|
54
|
+
access.set(entityType, await checkFeatures(features))
|
|
55
|
+
}
|
|
56
|
+
return access
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Runs a batch of widget-data requests against shared request-scoped
|
|
61
|
+
* dependencies (a single container, RBAC resolution, org-scope, and EM fork).
|
|
62
|
+
* Feature access is resolved once up front; each request is then executed
|
|
63
|
+
* concurrently with per-widget error isolation so one bad request never fails
|
|
64
|
+
* the whole batch.
|
|
65
|
+
*/
|
|
66
|
+
export async function runWidgetDataBatch(
|
|
67
|
+
entries: WidgetDataBatchEntry[],
|
|
68
|
+
deps: WidgetDataBatchDeps,
|
|
69
|
+
): Promise<WidgetDataBatchResult[]> {
|
|
70
|
+
const access = await resolveEntityFeatureAccess(
|
|
71
|
+
entries.map((entry) => entry.request.entityType),
|
|
72
|
+
deps.getRequiredFeatures,
|
|
73
|
+
deps.checkFeatures,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return Promise.all(
|
|
77
|
+
entries.map(async (entry): Promise<WidgetDataBatchResult> => {
|
|
78
|
+
if (access.get(entry.request.entityType) === false) {
|
|
79
|
+
return { id: entry.id, ok: false, error: 'Forbidden' }
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const data = await deps.fetchOne(entry.request)
|
|
83
|
+
return { id: entry.id, ok: true, data }
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { id: entry.id, ok: false, error: deps.describeError(error) }
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
5
|
-
import {
|
|
5
|
+
import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts'
|
|
8
8
|
import {
|
|
@@ -15,7 +15,7 @@ import { DEFAULT_SETTINGS, hydrateSettings, type AovKpiSettings } from './config
|
|
|
15
15
|
import type { WidgetDataResponse } from '../../../services/widgetDataService'
|
|
16
16
|
import { formatCurrencyWithDecimals } from '../../../lib/formatters'
|
|
17
17
|
|
|
18
|
-
async function fetchAovData(settings: AovKpiSettings): Promise<WidgetDataResponse> {
|
|
18
|
+
async function fetchAovData(settings: AovKpiSettings, fetchWidgetData: WidgetDataFetcher): Promise<WidgetDataResponse> {
|
|
19
19
|
const body = {
|
|
20
20
|
entityType: 'sales:orders',
|
|
21
21
|
metric: {
|
|
@@ -29,18 +29,7 @@ async function fetchAovData(settings: AovKpiSettings): Promise<WidgetDataRespons
|
|
|
29
29
|
comparison: settings.showComparison ? { type: 'previous_period' } : undefined,
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
method: 'POST',
|
|
34
|
-
headers: { 'Content-Type': 'application/json' },
|
|
35
|
-
body: JSON.stringify(body),
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
if (!call.ok) {
|
|
39
|
-
const errorMsg = (call.result as Record<string, unknown>)?.error
|
|
40
|
-
throw new Error(typeof errorMsg === 'string' ? errorMsg : 'Failed to fetch AOV data')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return call.result as WidgetDataResponse
|
|
32
|
+
return fetchWidgetData<WidgetDataResponse>(body)
|
|
44
33
|
}
|
|
45
34
|
|
|
46
35
|
const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
|
|
@@ -57,12 +46,13 @@ const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
|
|
|
57
46
|
const [loading, setLoading] = React.useState(true)
|
|
58
47
|
const [error, setError] = React.useState<string | null>(null)
|
|
59
48
|
|
|
49
|
+
const fetchWidgetData = useWidgetData()
|
|
60
50
|
const refresh = React.useCallback(async () => {
|
|
61
51
|
onRefreshStateChange?.(true)
|
|
62
52
|
setLoading(true)
|
|
63
53
|
setError(null)
|
|
64
54
|
try {
|
|
65
|
-
const data = await fetchAovData(hydrated)
|
|
55
|
+
const data = await fetchAovData(hydrated, fetchWidgetData)
|
|
66
56
|
setValue(data.value)
|
|
67
57
|
if (data.comparison) {
|
|
68
58
|
setTrend({
|
|
@@ -79,7 +69,7 @@ const AovKpiWidget: React.FC<DashboardWidgetComponentProps<AovKpiSettings>> = ({
|
|
|
79
69
|
setLoading(false)
|
|
80
70
|
onRefreshStateChange?.(false)
|
|
81
71
|
}
|
|
82
|
-
}, [hydrated, onRefreshStateChange, t])
|
|
72
|
+
}, [hydrated, fetchWidgetData, onRefreshStateChange, t])
|
|
83
73
|
|
|
84
74
|
React.useEffect(() => {
|
|
85
75
|
refresh().catch(() => {})
|