From cc25fff4eba53f1829c122ef881fd9a526467c6b Mon Sep 17 00:00:00 2001 From: Mahakisore7 Date: Sat, 19 Sep 2026 18:20:44 +0530 Subject: [PATCH] feat: owner-facing analytics dashboard (revenue, low-stock, trust score) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /dashboard, restricted to owner/manager via authContext's new canViewDashboard(). Reads existing data only — inventory_records via its ShopConflictIndex GSI and orders via its existing pk/sk query — so there are zero schema changes and no new audit_log GSI, keeping the write-path untouched. - revenue: total, order count, average order value, trailing 7-day window - low_stock: items at/under a configurable threshold (default 5), sorted ascending - trust_score: (total_items - items_with_open_conflict) / total_items, derived from inventory_records' current conflict_status Frontend gets a role-gated Dashboard nav link and DashboardPage showing the same breakdown, color-toned by trust-score severity. Tests: 6 new API tests (dashboardQuery.test.ts), 3 new web tests (DashboardPage.test.tsx), 2 new App.test.tsx nav-visibility tests, 2 new CDK tests for the Analytics construct — full CDK suite (34/34) verified green including the new tests. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/handlers/dashboardQuery.ts | 112 ++++++++++++++ apps/api/src/lib/authContext.ts | 5 + apps/api/src/local/server.ts | 6 + apps/api/test/dashboardQuery.test.ts | 171 +++++++++++++++++++++ apps/web/src/App.tsx | 5 +- apps/web/src/api/client.ts | 16 ++ apps/web/src/pages/DashboardPage.tsx | 111 +++++++++++++ apps/web/test/App.test.tsx | 16 ++ apps/web/test/pages/DashboardPage.test.tsx | 54 +++++++ infra/cdk/lib/constructs/Analytics.ts | 52 +++++++ infra/cdk/lib/stocksync-stack.ts | 11 ++ infra/cdk/test/stocksync-stack.test.ts | 30 ++++ 12 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/handlers/dashboardQuery.ts create mode 100644 apps/api/test/dashboardQuery.test.ts create mode 100644 apps/web/src/pages/DashboardPage.tsx create mode 100644 apps/web/test/pages/DashboardPage.test.tsx create mode 100644 infra/cdk/lib/constructs/Analytics.ts diff --git a/apps/api/src/handlers/dashboardQuery.ts b/apps/api/src/handlers/dashboardQuery.ts new file mode 100644 index 0000000..48eb604 --- /dev/null +++ b/apps/api/src/handlers/dashboardQuery.ts @@ -0,0 +1,112 @@ +import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; +import { QueryCommand } from "@aws-sdk/lib-dynamodb"; +import { ddb } from "../lib/dynamo"; +import { canViewDashboard, getAuthContext } from "../lib/authContext"; +import type { InventoryRecordItem } from "../lib/inventoryRecord"; + +const DEFAULT_LOW_STOCK_THRESHOLD = 5; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +interface OrderItem { + order_id: string; + total_amount: number; + created_at: string; +} + +function invalidPayload(message: string): APIGatewayProxyResultV2 { + return { statusCode: 400, body: JSON.stringify({ error: "invalid_payload", message }) }; +} + +function forbidden(message: string): APIGatewayProxyResultV2 { + return { statusCode: 403, body: JSON.stringify({ error: "forbidden", message }) }; +} + +async function fetchInventoryItems(shopId: string): Promise { + const result = await ddb.send( + new QueryCommand({ + TableName: process.env.INVENTORY_RECORDS_TABLE_NAME, + IndexName: "ShopConflictIndex", + KeyConditionExpression: "shop_id = :shopId", + ExpressionAttributeValues: { ":shopId": shopId }, + }), + ); + return (result.Items ?? []) as InventoryRecordItem[]; +} + +async function fetchOrders(shopId: string): Promise { + const result = await ddb.send( + new QueryCommand({ + TableName: process.env.ORDERS_TABLE_NAME, + KeyConditionExpression: "pk = :pk AND begins_with(sk, :skPrefix)", + ExpressionAttributeValues: { ":pk": `SHOP#${shopId}`, ":skPrefix": "ORDER#" }, + }), + ); + return (result.Items ?? []) as OrderItem[]; +} + +/** + * GET /dashboard?shop_id=X — owner/manager only (authContext.ts's + * canViewDashboard; counter_staff gets 403, same split as catalog writes). + * Deliberately reads existing tables with zero schema changes rather than + * adding a new rollup table or a shop-wide GSI on audit_log: at this + * product's scale (one shop's catalog/order history, not millions of + * rows), a per-shop Query + in-Lambda aggregation is simpler and safer + * than introducing new write-path complexity into the correctness-critical + * pipeline for a read-only reporting feature. Revisit only if a real + * shop's order/catalog volume makes this Query too slow. + * + * trust_score is deliberately computed from inventory_records' *current* + * conflict_status (via the existing ShopConflictIndex GSI), not audit_log + * history — audit_log's pk is scoped per-item (`SHOP#x#ITEM#y`), not + * per-shop, so there's no efficient shop-wide historical query without a + * new GSI (and a write-path change to every place that appends to + * audit_log). "% of the catalog with no open conflict right now" is an + * honest, useful proxy that needs none of that. + */ +export async function handler(event: APIGatewayProxyEventV2): Promise { + const auth = getAuthContext(event); + if (auth && !canViewDashboard(auth.role)) return forbidden("only owner/manager can view the dashboard"); + + const shopId = auth?.shopId ?? event.queryStringParameters?.shop_id; + if (!shopId) return invalidPayload("shop_id is required"); + + const lowStockThresholdRaw = event.queryStringParameters?.low_stock_threshold; + const lowStockThreshold = lowStockThresholdRaw ? Number(lowStockThresholdRaw) : DEFAULT_LOW_STOCK_THRESHOLD; + if (!Number.isFinite(lowStockThreshold) || lowStockThreshold < 0) { + return invalidPayload("low_stock_threshold must be a non-negative number"); + } + + const [items, orders] = await Promise.all([fetchInventoryItems(shopId), fetchOrders(shopId)]); + + const totalRevenue = orders.reduce((sum, order) => sum + order.total_amount, 0); + const sevenDaysAgo = Date.now() - 7 * MS_PER_DAY; + const last7DaysRevenue = orders + .filter((order) => new Date(order.created_at).getTime() >= sevenDaysAgo) + .reduce((sum, order) => sum + order.total_amount, 0); + + const lowStock = items + .filter((item) => item.stock <= lowStockThreshold) + .map((item) => ({ item_id: item.item_id, name: item.name, stock: item.stock })) + .sort((a, b) => a.stock - b.stock); + + const openConflicts = items.filter((item) => item.conflict_status === "needs_review").length; + const trustScorePercent = items.length === 0 ? 100 : Math.round(((items.length - openConflicts) / items.length) * 1000) / 10; + + return { + statusCode: 200, + body: JSON.stringify({ + revenue: { + total: totalRevenue, + order_count: orders.length, + average_order_value: orders.length === 0 ? 0 : Math.round((totalRevenue / orders.length) * 100) / 100, + last_7_days: last7DaysRevenue, + }, + low_stock: lowStock, + trust_score: { + percent: trustScorePercent, + total_items: items.length, + items_with_open_conflict: openConflicts, + }, + }), + }; +} diff --git a/apps/api/src/lib/authContext.ts b/apps/api/src/lib/authContext.ts index df56dd2..c86f414 100644 --- a/apps/api/src/lib/authContext.ts +++ b/apps/api/src/lib/authContext.ts @@ -66,3 +66,8 @@ export function canWriteCatalog(role: Role): boolean { export function canInviteStaff(role: Role): boolean { return role === "owner"; } + +/** Revenue/low-stock/trust-score numbers are a business-owner concern, not a counter-staff one — same role split as catalog writes, named separately since the two checks protect different things and shouldn't drift together by accident. */ +export function canViewDashboard(role: Role): boolean { + return CATALOG_WRITE_ROLES.includes(role); +} diff --git a/apps/api/src/local/server.ts b/apps/api/src/local/server.ts index 1c0cb99..9669f84 100644 --- a/apps/api/src/local/server.ts +++ b/apps/api/src/local/server.ts @@ -90,6 +90,7 @@ async function main(): Promise { const { handler: categoriesCrudHandler } = await import("../handlers/categoriesCrud"); const { handler: suppliersCrudHandler } = await import("../handlers/suppliersCrud"); const { handler: checkoutHandler } = await import("../handlers/checkout"); + const { handler: dashboardQueryHandler } = await import("../handlers/dashboardQuery"); const { sqs } = await import("../lib/sqs"); // ---- WebSocket: $connect / $disconnect + the raw connections used by @@ -279,6 +280,11 @@ async function main(): Promise { send(res, await checkoutHandler(event)); }); + app.get("/dashboard", async (req, res) => { + const event = { queryStringParameters: req.query } as unknown as APIGatewayProxyEventV2; + send(res, await dashboardQueryHandler(event)); + }); + app.listen(HTTP_PORT, () => { console.log(`[local-server] REST API listening on http://localhost:${HTTP_PORT}`); console.log(`[local-server] WebSocket listening on ws://localhost:${WS_PORT}`); diff --git a/apps/api/test/dashboardQuery.test.ts b/apps/api/test/dashboardQuery.test.ts new file mode 100644 index 0000000..3e48230 --- /dev/null +++ b/apps/api/test/dashboardQuery.test.ts @@ -0,0 +1,171 @@ +import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import { CreateTableCommand } from "@aws-sdk/client-dynamodb"; +import { PutCommand } from "@aws-sdk/lib-dynamodb"; +import dynalite from "dynalite"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const PORT = 8132; +const INVENTORY_TABLE = "inventory_records_test"; +const ORDERS_TABLE = "orders_test_dashboard"; +process.env.DYNAMODB_ENDPOINT = `http://localhost:${PORT}`; +process.env.INVENTORY_RECORDS_TABLE_NAME = INVENTORY_TABLE; +process.env.ORDERS_TABLE_NAME = ORDERS_TABLE; + +let dynaliteServer: ReturnType; +let handler: typeof import("../src/handlers/dashboardQuery").handler; +let ddb: typeof import("../src/lib/dynamo").ddb; + +function invoke( + claims: Record | undefined, + query: Record = {}, +): Promise<{ statusCode: number; body: string }> { + const event = { + requestContext: claims ? { authorizer: { jwt: { claims } } } : {}, + queryStringParameters: query, + } as unknown as APIGatewayProxyEventV2; + return handler(event) as Promise<{ statusCode: number; body: string }>; +} + +function parseBody(result: { body: string }): T { + return JSON.parse(result.body) as T; +} + +const OWNER_CLAIMS = { sub: "u-owner", "custom:shop_id": "dash-shop", "cognito:groups": "owner" }; +const STAFF_CLAIMS = { sub: "u-staff", "custom:shop_id": "dash-shop", "cognito:groups": "counter_staff" }; + +async function seedItem(overrides: Record): Promise { + await ddb.send( + new PutCommand({ + TableName: INVENTORY_TABLE, + Item: { + pk: `SHOP#dash-shop#ITEM#${overrides.item_id}`, + sk: "CURRENT", + shop_id: "dash-shop", + conflict_status: "none", + ...overrides, + }, + }), + ); +} + +async function seedOrder(orderId: string, totalAmount: number, createdAt: string): Promise { + await ddb.send( + new PutCommand({ + TableName: ORDERS_TABLE, + Item: { pk: "SHOP#dash-shop", sk: `ORDER#${orderId}`, order_id: orderId, total_amount: totalAmount, created_at: createdAt }, + }), + ); +} + +beforeAll(async () => { + dynaliteServer = dynalite({ createTableMs: 0 }); + await new Promise((resolve, reject) => { + dynaliteServer.listen(PORT, (err?: Error) => (err ? reject(err) : resolve())); + }); + + ({ ddb } = await import("../src/lib/dynamo")); + ({ handler } = await import("../src/handlers/dashboardQuery")); + + await ddb.send( + new CreateTableCommand({ + TableName: INVENTORY_TABLE, + AttributeDefinitions: [ + { AttributeName: "pk", AttributeType: "S" }, + { AttributeName: "sk", AttributeType: "S" }, + { AttributeName: "shop_id", AttributeType: "S" }, + { AttributeName: "conflict_status", AttributeType: "S" }, + ], + KeySchema: [ + { AttributeName: "pk", KeyType: "HASH" }, + { AttributeName: "sk", KeyType: "RANGE" }, + ], + GlobalSecondaryIndexes: [ + { + IndexName: "ShopConflictIndex", + KeySchema: [ + { AttributeName: "shop_id", KeyType: "HASH" }, + { AttributeName: "conflict_status", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + BillingMode: "PAY_PER_REQUEST", + }), + ); + await ddb.send( + new CreateTableCommand({ + TableName: ORDERS_TABLE, + AttributeDefinitions: [ + { AttributeName: "pk", AttributeType: "S" }, + { AttributeName: "sk", AttributeType: "S" }, + ], + KeySchema: [ + { AttributeName: "pk", KeyType: "HASH" }, + { AttributeName: "sk", KeyType: "RANGE" }, + ], + BillingMode: "PAY_PER_REQUEST", + }), + ); + + await seedItem({ item_id: "parle-g", name: "Parle-G 100g", stock: 40, conflict_status: "none" }); + await seedItem({ item_id: "milk-500ml", name: "Milk 500ml", stock: 2, conflict_status: "none" }); + await seedItem({ item_id: "rice-5kg", name: "Rice 5kg", stock: 0, conflict_status: "needs_review" }); + + const now = new Date(); + const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString(); + await seedOrder("ord-1", 100, now.toISOString()); + await seedOrder("ord-2", 50, now.toISOString()); + await seedOrder("ord-3", 25, eightDaysAgo); +}); + +afterAll(async () => { + await new Promise((resolve) => dynaliteServer.close(() => resolve())); +}); + +interface DashboardBody { + revenue: { total: number; order_count: number; average_order_value: number; last_7_days: number }; + low_stock: { item_id: string; name?: string; stock: number }[]; + trust_score: { percent: number; total_items: number; items_with_open_conflict: number }; +} + +describe("GET /dashboard", () => { + it("403s counter_staff — this is an owner/manager-only view", async () => { + const result = await invoke(STAFF_CLAIMS); + expect(result.statusCode).toBe(403); + }); + + it("computes revenue totals, a 7-day window, low-stock items, and a trust score, for an owner", async () => { + const result = await invoke(OWNER_CLAIMS); + expect(result.statusCode).toBe(200); + const body = parseBody(result); + + expect(body.revenue).toEqual({ total: 175, order_count: 3, average_order_value: 58.33, last_7_days: 150 }); + + expect(body.low_stock.map((item) => item.item_id)).toEqual(["rice-5kg", "milk-500ml"]); + + expect(body.trust_score).toEqual({ percent: 66.7, total_items: 3, items_with_open_conflict: 1 }); + }); + + it("uses the JWT's shop_id, not a client-supplied one, when an authorizer context is present", async () => { + const result = await invoke(OWNER_CLAIMS, { shop_id: "attacker-shop" }); + expect(result.statusCode).toBe(200); + expect(parseBody(result).revenue.order_count).toBe(3); + }); + + it("falls back to the query-string shop_id with no authorizer context (local dev)", async () => { + const result = await invoke(undefined, { shop_id: "dash-shop" }); + expect(result.statusCode).toBe(200); + expect(parseBody(result).revenue.order_count).toBe(3); + }); + + it("400s when shop_id is missing entirely", async () => { + const result = await invoke(undefined, {}); + expect(result.statusCode).toBe(400); + }); + + it("respects a custom low_stock_threshold", async () => { + const result = await invoke(OWNER_CLAIMS, { low_stock_threshold: "0" }); + const body = parseBody(result); + expect(body.low_stock.map((item) => item.item_id)).toEqual(["rice-5kg"]); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e2aa53..f260320 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,6 +6,7 @@ import { HeroPage } from "./pages/HeroPage"; import { ProductsPage } from "./pages/ProductsPage"; import { CheckoutPage } from "./pages/CheckoutPage"; import { StaffPage } from "./pages/StaffPage"; +import { DashboardPage } from "./pages/DashboardPage"; import { CounterPicker } from "./components/CounterPicker"; function useQueryParam(name: string, fallback: string): string { @@ -20,6 +21,7 @@ const NAV_LINKS = [ { page: "counter", label: "Counter", roles: ["owner", "manager", "counter_staff"] }, { page: "products", label: "Products", roles: ["owner", "manager", "counter_staff"] }, { page: "checkout", label: "Checkout", roles: ["owner", "manager", "counter_staff"] }, + { page: "dashboard", label: "Dashboard", roles: ["owner", "manager"] }, { page: "staff", label: "Staff", roles: ["owner"] }, ] as const; @@ -71,8 +73,9 @@ function AuthedApp({ shopId, role }: AuthedAppProps) { {page === "products" && } {page === "checkout" && } + {page === "dashboard" && (role === "owner" || role === "manager") && } {page === "staff" && role === "owner" && } - {page !== "products" && page !== "checkout" && page !== "staff" && } + {!["products", "checkout", "dashboard", "staff"].includes(page) && } ); } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index eef35db..04bbbd6 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -200,6 +200,22 @@ export async function postCheckout( return (await response.json()) as CheckoutResponse; } +export interface DashboardResponse { + revenue: { total: number; order_count: number; average_order_value: number; last_7_days: number }; + low_stock: { item_id: string; name?: string; stock: number }[]; + trust_score: { percent: number; total_items: number; items_with_open_conflict: number }; +} + +/** GET /dashboard — owner/manager only (server enforces via authContext.ts's canViewDashboard). */ +export async function getDashboard(shopId: string): Promise { + const params = new URLSearchParams({ shop_id: shopId }); + const response = await fetch(`${API_BASE_URL}/dashboard?${params.toString()}`, { headers: headers() }); + if (!response.ok) { + throw new Error(`GET /dashboard failed: ${response.status}`); + } + return (await response.json()) as DashboardResponse; +} + export interface StaffInviteResponse { email: string; role: "manager" | "counter_staff"; diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..6a2ec86 --- /dev/null +++ b/apps/web/src/pages/DashboardPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { getDashboard, type DashboardResponse } from "../api/client"; +import { AlertTriangleIcon, CheckCircleIcon, TagIcon } from "../components/icons"; + +export interface DashboardPageProps { + shopId: string; +} + +function trustScoreTone(percent: number): { text: string; ring: string } { + if (percent >= 90) return { text: "text-emerald-700", ring: "border-emerald-200 bg-emerald-50" }; + if (percent >= 70) return { text: "text-amber-700", ring: "border-amber-200 bg-amber-50" }; + return { text: "text-rose-700", ring: "border-rose-200 bg-rose-50" }; +} + +/** + * Owner-facing rollups (Phase 3 of the "complete product" roadmap) — a + * thin read-only view over GET /dashboard (dashboardQuery.ts). Server + * enforces owner/manager-only access independently (authContext.ts's + * canViewDashboard); App.tsx only rendering this link for those roles is + * a UX nicety, not the actual security boundary. + */ +export function DashboardPage({ shopId }: DashboardPageProps) { + const [data, setData] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + getDashboard(shopId) + .then((result) => { + if (!cancelled) setData(result); + }) + .catch(() => { + if (!cancelled) setError(true); + }); + return () => { + cancelled = true; + }; + }, [shopId]); + + if (error) { + return ( +
+

Couldn't load the dashboard. Check the connection and try again.

+
+ ); + } + + if (!data) { + return ( +
+

Loading…

+
+ ); + } + + const tone = trustScoreTone(data.trust_score.percent); + + return ( +
+

Dashboard

+ +
+
+

Total revenue

+

₹{data.revenue.total.toLocaleString()}

+

{data.revenue.order_count} orders · ₹{data.revenue.average_order_value} avg

+
+ +
+

Last 7 days

+

₹{data.revenue.last_7_days.toLocaleString()}

+
+ +
+

Trust score

+

+ + {data.trust_score.percent}% +

+

+ {data.trust_score.items_with_open_conflict} of {data.trust_score.total_items} items have an open conflict +

+
+
+ +
+

+ + Low stock +

+ {data.low_stock.length === 0 ? ( +

Nothing running low right now.

+ ) : ( +
    + {data.low_stock.map((item) => ( +
  • + + + {item.name ?? item.item_id} + + + {item.stock} left + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/test/App.test.tsx b/apps/web/test/App.test.tsx index cbf6acf..7b9701c 100644 --- a/apps/web/test/App.test.tsx +++ b/apps/web/test/App.test.tsx @@ -128,6 +128,22 @@ describe("App — signed in", () => { expect(screen.queryByRole("link", { name: /staff/i })).not.toBeInTheDocument(); }); + it("shows the Dashboard nav link for a manager but not for counter staff", async () => { + signInAs("manager"); + window.history.pushState({}, "", "/?counter_id=counter_a"); + render(); + await screen.findByText("Parle-G 100g"); + expect(screen.getByRole("link", { name: /dashboard/i })).toBeInTheDocument(); + }); + + it("hides the Dashboard nav link for counter staff", async () => { + signInAs("counter_staff"); + window.history.pushState({}, "", "/?counter_id=counter_a"); + render(); + await screen.findByText("Parle-G 100g"); + expect(screen.queryByRole("link", { name: /dashboard/i })).not.toBeInTheDocument(); + }); + it("going offline via the toggle and selling an item queues it, without calling fetch again", async () => { signInAs("owner"); window.history.pushState({}, "", "/?counter_id=counter_a"); diff --git a/apps/web/test/pages/DashboardPage.test.tsx b/apps/web/test/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..325e799 --- /dev/null +++ b/apps/web/test/pages/DashboardPage.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DashboardPage } from "../../src/pages/DashboardPage"; + +const getDashboardMock = vi.fn(); + +vi.mock("../../src/api/client", () => ({ + getDashboard: (...args: unknown[]) => getDashboardMock(...args), +})); + +const sampleData = { + revenue: { total: 175, order_count: 3, average_order_value: 58.33, last_7_days: 150 }, + low_stock: [ + { item_id: "rice-5kg", name: "Rice 5kg", stock: 0 }, + { item_id: "milk-500ml", name: "Milk 500ml", stock: 2 }, + ], + trust_score: { percent: 66.7, total_items: 3, items_with_open_conflict: 1 }, +}; + +beforeEach(() => { + getDashboardMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("DashboardPage", () => { + it("requests the dashboard for the given shop and renders revenue, 7-day, trust score, and low-stock rows", async () => { + getDashboardMock.mockResolvedValue(sampleData); + render(); + + expect(await screen.findByText("₹175")).toBeInTheDocument(); + expect(screen.getByText("₹150")).toBeInTheDocument(); + expect(screen.getByText("66.7%")).toBeInTheDocument(); + expect(screen.getByText("Rice 5kg")).toBeInTheDocument(); + expect(screen.getByText("0 left")).toBeInTheDocument(); + expect(getDashboardMock).toHaveBeenCalledWith("dash-shop"); + }); + + it("shows a friendly message when nothing is low on stock", async () => { + getDashboardMock.mockResolvedValue({ ...sampleData, low_stock: [] }); + render(); + + expect(await screen.findByText(/nothing running low/i)).toBeInTheDocument(); + }); + + it("shows an error message when the request fails", async () => { + getDashboardMock.mockRejectedValue(new Error("boom")); + render(); + + expect(await screen.findByText(/couldn't load the dashboard/i)).toBeInTheDocument(); + }); +}); diff --git a/infra/cdk/lib/constructs/Analytics.ts b/infra/cdk/lib/constructs/Analytics.ts new file mode 100644 index 0000000..64403eb --- /dev/null +++ b/infra/cdk/lib/constructs/Analytics.ts @@ -0,0 +1,52 @@ +import * as path from "node:path"; +import { Duration } from "aws-cdk-lib"; +import { HttpApi, HttpMethod } from "aws-cdk-lib/aws-apigatewayv2"; +import type { IHttpRouteAuthorizer } from "aws-cdk-lib/aws-apigatewayv2"; +import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations"; +import type { Table } from "aws-cdk-lib/aws-dynamodb"; +import { Runtime } from "aws-cdk-lib/aws-lambda"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { Construct } from "constructs"; + +export interface AnalyticsProps { + readonly httpApi: HttpApi; + readonly authorizer: IHttpRouteAuthorizer; + readonly inventoryRecordsTable: Table; + readonly ordersTable: Table; +} + +/** + * The owner-facing analytics dashboard (revenue rollups, low-stock alerts, + * a trust-score reframing of the conflict rate). Deliberately a + * read-only construct spanning two tables owned by other constructs + * (DataLayer's inventory_records, PlatformCrud's orders) — see + * dashboardQuery.ts's doc comment for why this reads existing tables + * directly instead of adding a new rollup table or GSI. + */ +export class Analytics extends Construct { + public readonly dashboardQueryFn: NodejsFunction; + + constructor(scope: Construct, id: string, props: AnalyticsProps) { + super(scope, id); + + this.dashboardQueryFn = new NodejsFunction(this, "DashboardQueryFunction", { + entry: path.join(__dirname, "../../../../apps/api/src/handlers/dashboardQuery.ts"), + handler: "handler", + runtime: Runtime.NODEJS_22_X, + timeout: Duration.seconds(10), + environment: { + INVENTORY_RECORDS_TABLE_NAME: props.inventoryRecordsTable.tableName, + ORDERS_TABLE_NAME: props.ordersTable.tableName, + }, + }); + props.inventoryRecordsTable.grantReadData(this.dashboardQueryFn); + props.ordersTable.grantReadData(this.dashboardQueryFn); + + props.httpApi.addRoutes({ + path: "/dashboard", + methods: [HttpMethod.GET], + integration: new HttpLambdaIntegration("DashboardQueryIntegration", this.dashboardQueryFn), + authorizer: props.authorizer, + }); + } +} diff --git a/infra/cdk/lib/stocksync-stack.ts b/infra/cdk/lib/stocksync-stack.ts index f4a1014..c988369 100644 --- a/infra/cdk/lib/stocksync-stack.ts +++ b/infra/cdk/lib/stocksync-stack.ts @@ -8,6 +8,7 @@ import { SyncEngine } from "./constructs/SyncEngine"; import { RealtimeApi } from "./constructs/RealtimeApi"; import { Observability } from "./constructs/Observability"; import { PlatformCrud } from "./constructs/PlatformCrud"; +import { Analytics } from "./constructs/Analytics"; export class StocksyncStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { @@ -70,5 +71,15 @@ export class StocksyncStack extends Stack { syncEngine.writeQueue.grantSendMessages(platformCrud.checkoutFn); platformCrud.checkoutFn.addEnvironment("WRITE_DEDUP_TABLE_NAME", dataLayer.writeDedupTable.tableName); platformCrud.checkoutFn.addEnvironment("WRITE_QUEUE_URL", syncEngine.writeQueue.queueUrl); + + // Owner-facing dashboard (revenue rollups, low-stock, trust score) — + // spans DataLayer's inventory_records and PlatformCrud's orders, so it + // can only be created here, once both exist. + new Analytics(this, "Analytics", { + httpApi: realtimeApi.httpApi, + authorizer: auth.authorizer, + inventoryRecordsTable: dataLayer.inventoryRecordsTable, + ordersTable: platformCrud.ordersTable, + }); } } diff --git a/infra/cdk/test/stocksync-stack.test.ts b/infra/cdk/test/stocksync-stack.test.ts index 65fde15..0483f87 100644 --- a/infra/cdk/test/stocksync-stack.test.ts +++ b/infra/cdk/test/stocksync-stack.test.ts @@ -417,6 +417,36 @@ describe("StocksyncStack — Auth (Cognito, real authentication)", () => { }); }); +describe("StocksyncStack — Analytics (Phase 3, owner dashboard)", () => { + it("exposes GET /dashboard behind the JWT authorizer", () => { + const template = synthTemplate(); + const routes = template.findResources("AWS::ApiGatewayV2::Route", { Properties: { RouteKey: "GET /dashboard" } }); + expect(Object.keys(routes).length).toBe(1); + const [route] = Object.values(routes) as { Properties: { AuthorizerId?: unknown } }[]; + expect(route.Properties.AuthorizerId).toBeDefined(); + }); + + it("grants the dashboard function read-only access to inventory_records and orders, nothing else", () => { + const template = synthTemplate(); + const policies = template.findResources("AWS::IAM::Policy"); + const dashboardPolicy = Object.values(policies).find((policy) => + JSON.stringify(policy).includes("DashboardQueryFunction"), + ); + expect(dashboardPolicy).toBeDefined(); + const statements = ( + dashboardPolicy as { Properties: { PolicyDocument: { Statement: { Action: string | string[] }[] } } } + ).Properties.PolicyDocument.Statement; + const actions = statements.flatMap((statement) => (Array.isArray(statement.Action) ? statement.Action : [statement.Action])); + + expect(actions.every((action) => !action.includes("Put") && !action.includes("Delete") && !action.includes("Update"))).toBe( + true, + ); + expect(actions.some((action) => action.startsWith("dynamodb:") && (action.includes("Query") || action.includes("Get")))).toBe( + true, + ); + }); +}); + describe("StocksyncStack — no wildcard IAM resources", () => { it("never grants a DynamoDB or SQS action against a wildcard resource", () => { const template = synthTemplate();