diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index c116519..00f46fb 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,14 @@ ## Protected Workflow Endpoints - `GET /api/waiter/floor/assignments?contextId=...` +- `GET /api/waiter/floor/activity?contextId=...` - `POST /api/waiter/floor/orders` +- `PUT /api/waiter/floor/orders/{orderId}` ## UI Workflow Coverage -- Waiter assignment lookup -- Floor order submission -- Protected route shell for assignments, order submission, and session inspection +- Waiter assignment snapshot with location metadata and active-order counts +- Recent waiter activity history feed +- Floor order submission and order update workflows +- Session-expired handling with reauthentication guidance +- Protected route shell for assignments, order actions, and session inspection diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index 89aac83..b46de46 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 + +- `/assignments`: waiter assignment snapshot and recent activity feed +- `/orders`: floor order submit and update actions +- `/session`: current Thalos session profile payload ## Build diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md index 89627d1..f896e88 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, activity loading, and order update payload mapping. - `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback. -- `src/App.test.tsx`: central login screen, protected assignment flow, and order submission workflow. +- `src/App.test.tsx`: central login screen, assignment and activity loading, order submit/update workflows, 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 aba0134..81d179b 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,17 +11,21 @@ vi.mock('./api/sessionApi', () => ({ vi.mock('./api/dashboardApi', () => ({ loadDashboard: vi.fn(), - submitFloorOrder: vi.fn() + loadRecentActivity: vi.fn(), + submitFloorOrder: vi.fn(), + updateFloorOrder: vi.fn() })); -import { loadDashboard, submitFloorOrder } from './api/dashboardApi'; +import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './api/dashboardApi'; import { getSessionMe } from './api/sessionApi'; import App from './App'; describe('Waiter Floor App', () => { beforeEach(() => { vi.mocked(loadDashboard).mockReset(); + vi.mocked(loadRecentActivity).mockReset(); vi.mocked(submitFloorOrder).mockReset(); + vi.mocked(updateFloorOrder).mockReset(); vi.mocked(getSessionMe).mockReset(); window.__APP_CONFIG__ = { API_BASE_URL: 'http://localhost:8080', @@ -42,14 +47,26 @@ describe('Waiter Floor App', () => { expect(link.href).toContain('tenantId=demo-tenant'); }); - it('loads assignments for authenticated users', async () => { + it('loads assignments and recent activity 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', + locationId: 'floor-a', + summary: '2 waiters assigned', + assignments: [{ waiterId: 'w-1', tableId: 'T-12', status: 'Assigned', activeOrders: 3 }], + recentActivity: ['legacy assignment feed'] + }); + vi.mocked(loadRecentActivity).mockResolvedValue({ + contextId: 'demo-context', + locationId: 'floor-a', + summary: 'activity', + recentActivity: ['Waiter w-1 picked up table T-12'] }); - vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'assigned' }); render(); @@ -57,24 +74,74 @@ describe('Waiter Floor App', () => { fireEvent.click(screen.getByRole('button', { name: 'Load Assignments' })); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); + expect(loadRecentActivity).toHaveBeenCalledWith('demo-context'); + expect(await screen.findByText('floor-a')).toBeInTheDocument(); + expect(await screen.findByText('Waiter w-1 picked up table T-12')).toBeInTheDocument(); }); - it('submits floor orders from action route', async () => { + it('submits and updates floor orders from the order route', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 + }); + vi.mocked(submitFloorOrder).mockResolvedValue({ + contextId: 'demo-context', + orderId: 'ORD-2200', + accepted: true, + summary: 'submitted', + status: 'Queued', + processedAtUtc: '2026-03-31T12:00:00Z' + }); + vi.mocked(updateFloorOrder).mockResolvedValue({ + contextId: 'demo-context', + orderId: 'ORD-2200', + accepted: true, + summary: 'updated', + status: 'Updated', + processedAtUtc: '2026-03-31T12:05:00Z' }); - vi.mocked(submitFloorOrder).mockResolvedValue({ orderId: 'ORD-1001', accepted: true, message: 'ok' }); render(); - await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Submit Order')); - fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } }); + await waitFor(() => expect(screen.getByText('Order Actions')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Order Actions')); + fireEvent.change(screen.getAllByPlaceholderText('Order Id')[0], { target: { value: 'ORD-2200' } }); fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' })); await waitFor(() => expect(submitFloorOrder).toHaveBeenCalledTimes(1)); + expect((await screen.findAllByText('Queued')).length).toBeGreaterThan(0); + + fireEvent.change(screen.getAllByPlaceholderText('Order Id')[1], { target: { value: 'ORD-2200' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update Floor Order' })); + + await waitFor(() => expect(updateFloorOrder).toHaveBeenCalledTimes(1)); + expect((await screen.findAllByText('Updated')).length).toBeGreaterThan(0); + }); + + it('shows reauthentication guidance when the workflow 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 Assignments' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Load Assignments' })); + + 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 2a59caf..72141c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,76 @@ -import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Layout, Menu, Result, Space, Spin, Table, Tag, Typography } from 'antd'; +import { + ClockCircleOutlined, + DeploymentUnitOutlined, + OrderedListOutlined, + ReloadOutlined, + ShoppingCartOutlined, + SyncOutlined +} from '@ant-design/icons'; +import { + Alert, + Button, + Card, + Descriptions, + Empty, + Form, + Input, + InputNumber, + Layout, + List, + Menu, + Result, + Space, + Spin, + Table, + Tag, + Typography +} 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 type { IdentityProvider } from './api/sessionApi'; import { SessionProvider, useSessionContext } from './auth/sessionContext'; import { buildGoogleOidcStartUrl } from './auth/oidcLogin'; import { loadDashboard, + loadRecentActivity, submitFloorOrder, + updateFloorOrder, type SubmitFloorOrderRequest, type SubmitFloorOrderResponse, - type WaiterAssignmentsResponse + type UpdateFloorOrderRequest, + type UpdateFloorOrderResponse, + type WaiterAssignmentsResponse, + type WaiterRecentActivityResponse } from './api/dashboardApi'; type AppRoute = '/assignments' | '/orders' | '/session'; +type OrderEvent = + | { kind: 'submitted'; response: SubmitFloorOrderResponse } + | { kind: 'updated'; response: UpdateFloorOrderResponse }; + +type WorkflowState = { + error: string | null; + sessionExpired: boolean; +}; const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [ { key: '/assignments', label: 'Assignments', icon: }, - { key: '/orders', label: 'Submit Order', icon: }, + { key: '/orders', label: 'Order Actions', icon: }, { key: '/session', label: 'Session', icon: } ]; +const assignmentColumns = [ + { title: 'Waiter Id', dataIndex: 'waiterId' }, + { title: 'Table Id', dataIndex: 'tableId' }, + { + title: 'Status', + dataIndex: 'status', + render: (value: string) => {value} + }, + { title: 'Active Orders', dataIndex: 'activeOrders' } +]; + function App() { return ( @@ -38,11 +88,13 @@ function WaiterFloorShell() { const [contextId, setContextId] = useState('demo-context'); const [assignments, setAssignments] = useState(null); - const [orderResponse, setOrderResponse] = useState(null); - const [orderHistory, setOrderHistory] = useState([]); - const [globalError, setGlobalError] = useState(null); + const [recentActivity, setRecentActivity] = useState(null); + const [lastOrderResponse, setLastOrderResponse] = useState(null); + const [orderHistory, setOrderHistory] = useState([]); + const [workflowState, setWorkflowState] = useState({ error: null, sessionExpired: false }); const [loadingAssignments, setLoadingAssignments] = useState(false); const [submittingOrder, setSubmittingOrder] = useState(false); + const [updatingOrder, setUpdatingOrder] = useState(false); const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []); const selectedKey = useMemo(() => { @@ -50,14 +102,33 @@ function WaiterFloorShell() { return candidate?.key ?? '/assignments'; }, [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 floor operations.', sessionExpired: true }); + await session.revalidate(); + return; + } + + setWorkflowState({ + error: err instanceof Error ? err.message : fallbackMessage, + sessionExpired: false + }); + }; + const loadAssignments = async () => { setLoadingAssignments(true); - setGlobalError(null); + clearWorkflowError(); try { - const payload = await loadDashboard(contextId); - setAssignments(payload); + const [assignmentsPayload, activityPayload] = await Promise.all([ + loadDashboard(contextId), + loadRecentActivity(contextId) + ]); + setAssignments(assignmentsPayload); + setRecentActivity(activityPayload); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to load waiter assignments.'); + await handleWorkflowFailure(err, 'Failed to load waiter assignments.'); } finally { setLoadingAssignments(false); } @@ -65,18 +136,32 @@ function WaiterFloorShell() { const submitOrder = async (request: SubmitFloorOrderRequest) => { setSubmittingOrder(true); - setGlobalError(null); + clearWorkflowError(); try { const payload = await submitFloorOrder(request); - setOrderResponse(payload); - setOrderHistory((previous) => [payload, ...previous].slice(0, 8)); + setLastOrderResponse(payload); + setOrderHistory((previous) => [{ kind: 'submitted' as const, response: payload }, ...previous].slice(0, 8)); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to submit floor order.'); + await handleWorkflowFailure(err, 'Failed to submit floor order.'); } finally { setSubmittingOrder(false); } }; + const reviseOrder = async (request: UpdateFloorOrderRequest) => { + setUpdatingOrder(true); + clearWorkflowError(); + try { + const payload = await updateFloorOrder(request); + setLastOrderResponse(payload); + setOrderHistory((previous) => [{ kind: 'updated' as const, response: payload }, ...previous].slice(0, 8)); + } catch (err) { + await handleWorkflowFailure(err, 'Failed to update floor order.'); + } finally { + setUpdatingOrder(false); + } + }; + if (session.status === 'loading') { return (
@@ -99,6 +184,15 @@ function WaiterFloorShell() { } /> {session.error && } + {workflowState.sessionExpired && ( + + )} ); } @@ -133,45 +227,85 @@ function WaiterFloorShell() { Waiter Floor Operations - Protected floor workflows for assignment visibility and order submission. + Protected floor workflows for assignment visibility, recent activity, and order submit or update actions. {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 }} + /> + + + + {assignments ? ( + <> + + {assignments.contextId} + {assignments.locationId} + {assignments.summary} + + `${record.waiterId}-${record.tableId}`} + dataSource={assignments.assignments} + columns={assignmentColumns} + locale={{ emptyText: 'No active waiter assignments for this context.' }} + /> + + ) : ( + + )} - {assignments ? ( - - {assignments.contextId} - {assignments.summary} - + + }> + {recentActivity && recentActivity.recentActivity.length > 0 ? ( + {item}} + /> ) : ( - No assignment snapshot loaded. + )} - - + + } /> - + +
- {orderResponse && ( +
+ +
void reviseOrder(values)} + > + + + + + + + + + + + + + + +
+ + {lastOrderResponse ? ( - {orderResponse.orderId} - {String(orderResponse.accepted)} - {orderResponse.message} + {lastOrderResponse.contextId} + {lastOrderResponse.orderId} + {String(lastOrderResponse.accepted)} + {lastOrderResponse.status} + {lastOrderResponse.summary} + {lastOrderResponse.processedAtUtc} + ) : ( + )} - + + + pagination={false} - rowKey={(record) => record.orderId} + rowKey={(record) => `${record.kind}-${record.response.orderId}-${record.response.processedAtUtc}`} dataSource={orderHistory} + locale={{ emptyText: 'No recent floor order actions yet.' }} columns={[ - { title: 'Order Id', dataIndex: 'orderId' }, { - title: 'Accepted', - render: (_, record) => {String(record.accepted)} + title: 'Action', + render: (_, record) => {record.kind} }, - { title: 'Message', dataIndex: 'message' } + { title: 'Order Id', render: (_, record) => record.response.orderId }, + { title: 'Status', render: (_, record) => record.response.status }, + { title: 'Summary', render: (_, record) => record.response.summary }, + { title: 'Processed At', render: (_, record) => record.response.processedAtUtc } ]} /> -
- + +
} /> ( }); } +export async function putJson( + path: string, + body: unknown, + baseUrl = getApiBaseUrl() +): Promise { + return requestJson(baseUrl, path, { + method: 'PUT', + body: JSON.stringify(body) + }); +} + export async function postNoContent( path: string, body: unknown, diff --git a/src/api/dashboardApi.test.ts b/src/api/dashboardApi.test.ts index 8c54030..4e106ff 100644 --- a/src/api/dashboardApi.test.ts +++ b/src/api/dashboardApi.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('./client', () => ({ getJson: vi.fn(), - postJson: vi.fn() + postJson: vi.fn(), + putJson: vi.fn() })); -import { getJson, postJson } from './client'; -import { loadDashboard, submitFloorOrder } from './dashboardApi'; +import { getJson, postJson, putJson } from './client'; +import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './dashboardApi'; describe('waiter floor dashboard api', () => { it('builds encoded assignments endpoint path', async () => { @@ -17,6 +18,14 @@ describe('waiter floor dashboard api', () => { expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/assignments?contextId=ctx%20floor%2F1'); }); + it('builds encoded activity endpoint path', async () => { + vi.mocked(getJson).mockResolvedValue({ ok: true }); + + await loadRecentActivity('ctx floor/1'); + + expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/activity?contextId=ctx%20floor%2F1'); + }); + it('posts floor order payload', async () => { vi.mocked(postJson).mockResolvedValue({ accepted: true }); @@ -34,4 +43,21 @@ describe('waiter floor dashboard api', () => { itemCount: 2 }); }); + + it('puts floor order update payload without duplicating order id in the body', async () => { + vi.mocked(putJson).mockResolvedValue({ accepted: true }); + + await updateFloorOrder({ + contextId: 'ctx', + tableId: 'T-1', + orderId: 'ORD/1', + itemCount: 4 + }); + + expect(putJson).toHaveBeenCalledWith('/api/waiter/floor/orders/ORD%2F1', { + contextId: 'ctx', + tableId: 'T-1', + itemCount: 4 + }); + }); }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 51c9e97..35b91ed 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,8 +1,25 @@ -import { getJson, postJson } from './client'; +import { getJson, postJson, putJson } from './client'; + +export type WaiterAssignment = { + waiterId: string; + tableId: string; + status: string; + activeOrders: number; +}; export type WaiterAssignmentsResponse = { contextId: string; + locationId: string; summary: string; + assignments: WaiterAssignment[]; + recentActivity: string[]; +}; + +export type WaiterRecentActivityResponse = { + contextId: string; + locationId: string; + summary: string; + recentActivity: string[]; }; export type SubmitFloorOrderRequest = { @@ -13,15 +30,46 @@ export type SubmitFloorOrderRequest = { }; export type SubmitFloorOrderResponse = { + contextId: string; orderId: string; accepted: boolean; - message: string; + summary: string; + status: string; + processedAtUtc: string; +}; + +export type UpdateFloorOrderRequest = { + contextId: string; + tableId: string; + orderId: string; + itemCount: number; +}; + +export type UpdateFloorOrderResponse = { + contextId: string; + orderId: string; + accepted: boolean; + summary: string; + status: string; + processedAtUtc: string; }; export async function loadDashboard(contextId: string): Promise { return getJson(`/api/waiter/floor/assignments?contextId=${encodeURIComponent(contextId)}`); } +export async function loadRecentActivity(contextId: string): Promise { + return getJson(`/api/waiter/floor/activity?contextId=${encodeURIComponent(contextId)}`); +} + export async function submitFloorOrder(request: SubmitFloorOrderRequest): Promise { return postJson('/api/waiter/floor/orders', request); } + +export async function updateFloorOrder(request: UpdateFloorOrderRequest): Promise { + return putJson(`/api/waiter/floor/orders/${encodeURIComponent(request.orderId)}`, { + contextId: request.contextId, + tableId: request.tableId, + itemCount: request.itemCount + }); +}