Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/api/src/handlers/dashboardQuery.ts
Original file line number Diff line number Diff line change
@@ -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<InventoryRecordItem[]> {
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<OrderItem[]> {
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<APIGatewayProxyResultV2> {
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,
},
}),
};
}
5 changes: 5 additions & 0 deletions apps/api/src/lib/authContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
6 changes: 6 additions & 0 deletions apps/api/src/local/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async function main(): Promise<void> {
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
Expand Down Expand Up @@ -279,6 +280,11 @@ async function main(): Promise<void> {
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}`);
Expand Down
171 changes: 171 additions & 0 deletions apps/api/test/dashboardQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof dynalite>;
let handler: typeof import("../src/handlers/dashboardQuery").handler;
let ddb: typeof import("../src/lib/dynamo").ddb;

function invoke(
claims: Record<string, string> | undefined,
query: Record<string, string> = {},
): 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<T>(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<string, unknown>): Promise<void> {
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<void> {
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<void>((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<void>((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<DashboardBody>(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<DashboardBody>(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<DashboardBody>(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<DashboardBody>(result);
expect(body.low_stock.map((item) => item.item_id)).toEqual(["rice-5kg"]);
});
});
5 changes: 4 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -71,8 +73,9 @@ function AuthedApp({ shopId, role }: AuthedAppProps) {
</nav>
{page === "products" && <ProductsPage shopId={shopId} />}
{page === "checkout" && <CheckoutPage shopId={shopId} counterId={counterId} />}
{page === "dashboard" && (role === "owner" || role === "manager") && <DashboardPage shopId={shopId} />}
{page === "staff" && role === "owner" && <StaffPage />}
{page !== "products" && page !== "checkout" && page !== "staff" && <CounterPage />}
{!["products", "checkout", "dashboard", "staff"].includes(page) && <CounterPage />}
</ShopProvider>
);
}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DashboardResponse> {
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";
Expand Down
Loading
Loading