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 }}
+ />
+
+ } onClick={() => void loadStatus()} disabled={loadingStatus}>
+ Retry
+
+
+ {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}} />
+ ) : (
+
+ )}
+
+
}
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {orderResponse && (
-
- {orderResponse.contextId}
- {orderResponse.orderId}
- {String(orderResponse.accepted)}
- {orderResponse.summary}
-
- )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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);
}