From 429b58a62989bfe5d8050196d6408d332b909ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 16:56:26 -0600 Subject: [PATCH] feat(customer-orders-web): deepen customer order workflows --- docs/architecture/frontend-boundary.md | 10 +- docs/runbooks/local-development.md | 7 + docs/runbooks/testing.md | 5 +- src/App.test.tsx | 65 +++++- src/App.tsx | 291 +++++++++++++++++++------ src/api/dashboardApi.test.ts | 18 +- src/api/dashboardApi.ts | 35 +++ 7 files changed, 349 insertions(+), 82 deletions(-) diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index 751e303..28cc840 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -5,6 +5,7 @@ - The UI does not access DAL or internal services directly. - Route shell uses Ant Design layout/menu and keeps business views behind session checks. - Unauthenticated users are redirected to the central auth host OIDC start endpoint. +- Session-expired responses are treated as an auth boundary concern and trigger revalidation before the UI prompts for login again. ## Runtime Base URLs @@ -16,10 +17,15 @@ ## Protected Workflow Endpoints - `GET /api/customer/orders/status?contextId=...` +- `GET /api/customer/orders/history?contextId=...` +- `GET /api/customer/orders/{orderId}?contextId=...` - `POST /api/customer/orders` ## UI Workflow Coverage -- Customer order status lookup -- Customer order submission +- Customer order status dashboard with current orders +- Selected order detail lookup +- Recent order history and event feed +- Customer order submission and recent submission results +- Session-expired handling with reauthentication guidance - Protected route shell for status, submission, and session inspection diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index 1bcbdde..777b87e 100644 --- a/docs/runbooks/local-development.md +++ b/docs/runbooks/local-development.md @@ -21,6 +21,13 @@ npm run dev - Login is executed via central Thalos OIDC start endpoint. - Business calls are gated behind session checks. - Session cookies are sent with `credentials: include`. +- Workflow calls that return `401` trigger session revalidation and then guide the user back to central login. + +## Available Screens + +- `/status`: current order status, selected order detail, history, and recent events +- `/submit`: customer order submission and recent submission results +- `/session`: current Thalos session profile payload ## Build diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md index 045c325..07ca6d3 100644 --- a/docs/runbooks/testing.md +++ b/docs/runbooks/testing.md @@ -15,10 +15,11 @@ npm run test:ci ## Coverage Scope - `src/api/client.test.ts`: runtime-config precedence and fallback behavior. -- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping. +- `src/api/dashboardApi.test.ts`: endpoint path/query composition for status, detail, history, and submit flows. - `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback. -- `src/App.test.tsx`: central login screen, protected status flow, and order submission workflow. +- `src/App.test.tsx`: central login screen, status/detail/history loading, order submission, and session-expired reauthentication guidance. ## Notes - Use containerized Node execution when host `npm` is unavailable. +- Prefer container-first validation before opening or updating runtime stack images. diff --git a/src/App.test.tsx b/src/App.test.tsx index 229b35e..5fd772e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from './api/client'; vi.mock('./api/sessionApi', () => ({ getSessionMe: vi.fn(), @@ -10,16 +11,20 @@ vi.mock('./api/sessionApi', () => ({ vi.mock('./api/dashboardApi', () => ({ loadDashboard: vi.fn(), + loadOrderDetail: vi.fn(), + loadOrderHistory: vi.fn(), submitCustomerOrder: vi.fn() })); -import { loadDashboard, submitCustomerOrder } from './api/dashboardApi'; +import { loadDashboard, loadOrderDetail, loadOrderHistory, submitCustomerOrder } from './api/dashboardApi'; import { getSessionMe } from './api/sessionApi'; import App from './App'; describe('Customer Orders App', () => { beforeEach(() => { vi.mocked(loadDashboard).mockReset(); + vi.mocked(loadOrderDetail).mockReset(); + vi.mocked(loadOrderHistory).mockReset(); vi.mocked(submitCustomerOrder).mockReset(); vi.mocked(getSessionMe).mockReset(); window.__APP_CONFIG__ = { @@ -42,14 +47,31 @@ describe('Customer Orders App', () => { expect(link.href).toContain('tenantId=demo-tenant'); }); - it('loads order status for authenticated users', async () => { + it('loads status, history, and detail for authenticated users', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 + }); + vi.mocked(loadDashboard).mockResolvedValue({ + contextId: 'demo-context', + summary: '2 open orders', + orders: [{ orderId: 'CO-1001', tableId: 'T-08', status: 'Submitted', guestCount: 2, itemIds: ['ITEM-101'] }], + recentEvents: ['status payload event'] + }); + vi.mocked(loadOrderHistory).mockResolvedValue({ + contextId: 'demo-context', + summary: 'recent history', + orders: [{ orderId: 'CO-0999', tableId: 'T-04', status: 'Completed', guestCount: 3, itemIds: ['ITEM-202'] }], + recentEvents: ['Order CO-0999 completed'] + }); + vi.mocked(loadOrderDetail).mockResolvedValue({ + contextId: 'demo-context', + summary: 'selected order', + order: { orderId: 'CO-1001', tableId: 'T-08', status: 'Submitted', guestCount: 2, itemIds: ['ITEM-101'] }, + recentEvents: ['Order CO-1001 confirmed'] }); - vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'open' }); render(); @@ -57,6 +79,10 @@ describe('Customer Orders App', () => { fireEvent.click(screen.getByRole('button', { name: 'Load Status' })); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); + expect(loadOrderHistory).toHaveBeenCalledWith('demo-context'); + expect(loadOrderDetail).toHaveBeenCalledWith('demo-context', 'CO-1001'); + expect(await screen.findByText('2 open orders')).toBeInTheDocument(); + expect(await screen.findByText('Order CO-0999 completed')).toBeInTheDocument(); }); it('submits customer order from action route', async () => { @@ -64,13 +90,14 @@ describe('Customer Orders App', () => { isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 }); vi.mocked(submitCustomerOrder).mockResolvedValue({ contextId: 'demo-context', orderId: 'CO-2200', accepted: true, - summary: 'accepted' + summary: 'accepted', + status: 'Submitted' }); render(); @@ -81,5 +108,31 @@ describe('Customer Orders App', () => { fireEvent.click(screen.getByRole('button', { name: 'Submit Customer Order' })); await waitFor(() => expect(submitCustomerOrder).toHaveBeenCalledTimes(1)); + expect((await screen.findAllByText('Submitted')).length).toBeGreaterThan(0); + }); + + it('shows reauthentication guidance when status loading returns session expired', async () => { + vi.mocked(getSessionMe) + .mockResolvedValueOnce({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 2 + }) + .mockResolvedValueOnce({ + isAuthenticated: false, + subjectId: '', + tenantId: '', + provider: 2 + }); + vi.mocked(loadDashboard).mockRejectedValue(new ApiError(401, 'No active session.', 'session_missing', 'corr-1')); + + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Load Status' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Load Status' })); + + await waitFor(() => expect(screen.getByText('Authentication Required')).toBeInTheDocument()); + expect(screen.getByText('Session expired')).toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index dc07240..fa33d9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,22 @@ -import { DeploymentUnitOutlined, ReloadOutlined, ShoppingCartOutlined, UnorderedListOutlined } from '@ant-design/icons'; +import { + ClockCircleOutlined, + DeploymentUnitOutlined, + ReloadOutlined, + ShoppingCartOutlined, + SyncOutlined, + UnorderedListOutlined +} from '@ant-design/icons'; import { Alert, Button, Card, Descriptions, + Empty, Form, Input, InputNumber, Layout, + List, Menu, Result, Space, @@ -18,10 +27,16 @@ import { } from 'antd'; import { type ReactNode, useMemo, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ApiError } from './api/client'; import { loadDashboard, + loadOrderDetail, + loadOrderHistory, submitCustomerOrder, + type CustomerOrderDetailResponse, + type CustomerOrderHistoryResponse, type CustomerOrderStatusResponse, + type CustomerOrderSummary, type SubmitCustomerOrderRequest, type SubmitCustomerOrderResponse } from './api/dashboardApi'; @@ -39,12 +54,32 @@ type SubmitOrderFormValues = { itemIdsText: string; }; +type WorkflowState = { + error: string | null; + sessionExpired: boolean; +}; + const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [ { key: '/status', label: 'Order Status', icon: }, { key: '/submit', label: 'Submit Order', icon: }, { key: '/session', label: 'Session', icon: } ]; +const orderColumns = [ + { title: 'Order Id', dataIndex: 'orderId' }, + { title: 'Table Id', dataIndex: 'tableId' }, + { + title: 'Status', + dataIndex: 'status', + render: (value: string) => {value} + }, + { title: 'Guests', dataIndex: 'guestCount' }, + { + title: 'Items', + render: (_: unknown, record: CustomerOrderSummary) => record.itemIds.join(', ') + } +]; + function App() { return ( @@ -61,10 +96,13 @@ function CustomerOrdersShell() { const navigate = useNavigate(); const [contextId, setContextId] = useState('demo-context'); + const [statusOrderId, setStatusOrderId] = useState('CO-1001'); const [statusPayload, setStatusPayload] = useState(null); + const [detailPayload, setDetailPayload] = useState(null); + const [historyPayload, setHistoryPayload] = useState(null); const [orderResponse, setOrderResponse] = useState(null); const [orderHistory, setOrderHistory] = useState([]); - const [globalError, setGlobalError] = useState(null); + const [workflowState, setWorkflowState] = useState({ error: null, sessionExpired: false }); const [loadingStatus, setLoadingStatus] = useState(false); const [submittingOrder, setSubmittingOrder] = useState(false); @@ -74,14 +112,38 @@ function CustomerOrdersShell() { return candidate?.key ?? '/status'; }, [location.pathname]); + const clearWorkflowError = () => setWorkflowState({ error: null, sessionExpired: false }); + + const handleWorkflowFailure = async (err: unknown, fallbackMessage: string) => { + if (err instanceof ApiError && err.status === 401) { + setWorkflowState({ + error: 'Your session expired. Sign in again to continue customer order workflows.', + sessionExpired: true + }); + await session.revalidate(); + return; + } + + setWorkflowState({ + error: err instanceof Error ? err.message : fallbackMessage, + sessionExpired: false + }); + }; + const loadStatus = async () => { setLoadingStatus(true); - setGlobalError(null); + clearWorkflowError(); try { - const payload = await loadDashboard(contextId); - setStatusPayload(payload); + const [statusResult, historyResult, detailResult] = await Promise.all([ + loadDashboard(contextId), + loadOrderHistory(contextId), + statusOrderId.trim().length > 0 ? loadOrderDetail(contextId, statusOrderId.trim()) : Promise.resolve(null) + ]); + setStatusPayload(statusResult); + setHistoryPayload(historyResult); + setDetailPayload(detailResult); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to load customer order status.'); + await handleWorkflowFailure(err, 'Failed to load customer order status.'); } finally { setLoadingStatus(false); } @@ -89,7 +151,7 @@ function CustomerOrdersShell() { const submitOrder = async (values: SubmitOrderFormValues) => { setSubmittingOrder(true); - setGlobalError(null); + clearWorkflowError(); const request: SubmitCustomerOrderRequest = { contextId: values.contextId, @@ -105,10 +167,10 @@ function CustomerOrdersShell() { try { const payload = await submitCustomerOrder(request); setOrderResponse(payload); - // Keep recent responses bounded so the session view stays readable over long demos. setOrderHistory((previous) => [payload, ...previous].slice(0, 8)); + setStatusOrderId(payload.orderId); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to submit customer order.'); + await handleWorkflowFailure(err, 'Failed to submit customer order.'); } finally { setSubmittingOrder(false); } @@ -136,6 +198,15 @@ function CustomerOrdersShell() { } /> {session.error && } + {workflowState.sessionExpired && ( + + )} ); } @@ -170,87 +241,164 @@ function CustomerOrdersShell() { Customer Orders - Protected order workflows for status lookup and order submission through the customer-orders BFF. + Protected order workflows for status, detail, history, and submission through the customer-orders BFF. {session.error && } - {globalError && } + {workflowState.error && ( + + Reauthenticate + + ) : undefined + } + /> + )} - - - setContextId(event.target.value)} - placeholder="Context Id" - style={{ width: 280 }} - /> - + + + + + setContextId(event.target.value)} + placeholder="Context Id" + style={{ width: 280 }} + /> + setStatusOrderId(event.target.value)} + placeholder="Order Id (optional detail)" + style={{ width: 280 }} + /> + + + + {statusPayload ? ( + <> + + {statusPayload.contextId} + {statusPayload.summary} + + + pagination={false} + rowKey={(record) => record.orderId} + dataSource={statusPayload.orders} + columns={orderColumns} + locale={{ emptyText: 'No current customer orders for this context.' }} + /> + + ) : ( + + )} - {statusPayload ? ( + + + {detailPayload?.order ? ( - {statusPayload.contextId} - {statusPayload.summary} + {detailPayload.contextId} + {detailPayload.summary} + {detailPayload.order.orderId} + {detailPayload.order.tableId} + {detailPayload.order.status} + {detailPayload.order.guestCount} + {detailPayload.order.itemIds.join(', ')} ) : ( - No order status loaded. + )} - - + + + {historyPayload ? ( + + pagination={false} + rowKey={(record) => `${historyPayload.contextId}-${record.orderId}`} + dataSource={historyPayload.orders} + columns={orderColumns} + locale={{ emptyText: 'No recent customer orders for this context.' }} + /> + ) : ( + + )} + + }> + {historyPayload && historyPayload.recentEvents.length > 0 ? ( + {item}} /> + ) : ( + + )} + + } /> - - - layout="vertical" - initialValues={{ - contextId, - orderId: 'CO-1001', - tableId: 'T-08', - guestCount: 2, - itemIdsText: 'ITEM-101,ITEM-202' - }} - onFinish={(values) => void submitOrder(values)} - > - - - - - - - - - - - - - - - - - - {orderResponse && ( - - {orderResponse.contextId} - {orderResponse.orderId} - {String(orderResponse.accepted)} - {orderResponse.summary} - - )} + + + + + layout="vertical" + initialValues={{ + contextId, + orderId: 'CO-1001', + tableId: 'T-08', + guestCount: 2, + itemIdsText: 'ITEM-101,ITEM-202' + }} + onFinish={(values) => void submitOrder(values)} + > + + + + + + + + + + + + + + + + + + {orderResponse ? ( + + {orderResponse.contextId} + {orderResponse.orderId} + {String(orderResponse.accepted)} + {orderResponse.status} + {orderResponse.summary} + + ) : ( + + )} + + + pagination={false} rowKey={(record) => `${record.contextId}-${record.orderId}`} dataSource={orderHistory} + locale={{ emptyText: 'No recent customer order submissions yet.' }} columns={[ { title: 'Order Id', dataIndex: 'orderId' }, { title: 'Context Id', dataIndex: 'contextId' }, @@ -258,11 +406,12 @@ function CustomerOrdersShell() { title: 'Accepted', render: (_, record) => {String(record.accepted)} }, + { title: 'Status', dataIndex: 'status' }, { title: 'Summary', dataIndex: 'summary' } ]} /> - - + + } /> ({ })); import { getJson, postJson } from './client'; -import { loadDashboard, submitCustomerOrder } from './dashboardApi'; +import { loadDashboard, loadOrderDetail, loadOrderHistory, submitCustomerOrder } from './dashboardApi'; describe('customer orders dashboard api', () => { it('builds encoded status endpoint path', async () => { @@ -17,6 +17,22 @@ describe('customer orders dashboard api', () => { expect(getJson).toHaveBeenCalledWith('/api/customer/orders/status?contextId=ctx%20customer%2F1'); }); + it('builds encoded detail endpoint path', async () => { + vi.mocked(getJson).mockResolvedValue({ ok: true }); + + await loadOrderDetail('ctx customer/1', 'CO/1'); + + expect(getJson).toHaveBeenCalledWith('/api/customer/orders/CO%2F1?contextId=ctx%20customer%2F1'); + }); + + it('builds encoded history endpoint path', async () => { + vi.mocked(getJson).mockResolvedValue({ ok: true }); + + await loadOrderHistory('ctx customer/1'); + + expect(getJson).toHaveBeenCalledWith('/api/customer/orders/history?contextId=ctx%20customer%2F1'); + }); + it('posts customer order payload', async () => { vi.mocked(postJson).mockResolvedValue({ accepted: true }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 86afc5e..d5eeb54 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,8 +1,32 @@ import { getJson, postJson } from './client'; +export type CustomerOrderSummary = { + orderId: string; + tableId: string; + status: string; + guestCount: number; + itemIds: string[]; +}; + export type CustomerOrderStatusResponse = { contextId: string; summary: string; + orders: CustomerOrderSummary[]; + recentEvents: string[]; +}; + +export type CustomerOrderDetailResponse = { + contextId: string; + summary: string; + order: CustomerOrderSummary | null; + recentEvents: string[]; +}; + +export type CustomerOrderHistoryResponse = { + contextId: string; + summary: string; + orders: CustomerOrderSummary[]; + recentEvents: string[]; }; export type SubmitCustomerOrderRequest = { @@ -18,12 +42,23 @@ export type SubmitCustomerOrderResponse = { orderId: string; accepted: boolean; summary: string; + status: string; }; export async function loadDashboard(contextId: string): Promise { return getJson(`/api/customer/orders/status?contextId=${encodeURIComponent(contextId)}`); } +export async function loadOrderDetail(contextId: string, orderId: string): Promise { + return getJson( + `/api/customer/orders/${encodeURIComponent(orderId)}?contextId=${encodeURIComponent(contextId)}` + ); +} + +export async function loadOrderHistory(contextId: string): Promise { + return getJson(`/api/customer/orders/history?contextId=${encodeURIComponent(contextId)}`); +} + export async function submitCustomerOrder(request: SubmitCustomerOrderRequest): Promise { return postJson('/api/customer/orders', request); }