feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer

Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.

shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
  BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
  409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling

web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
  currentState; client.ts interceptor throws it on 409 (WEB-M2 401
  handling untouched)
- guarded mutations read the token from the cached proposal detail at
  mutate time; the save flow chains rotated tokens (PUT response token
  into the bulk replace) and ends with a detail refetch so
  approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
  detail cache, invalidateProposalViews() (stale-queue invariant holds
  on the failure path too), and toast the conflict instead of the
  generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
  interceptor ConflictError paths, token threading/rotation, and 409
  cache recovery

Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
This commit is contained in:
Adam Moussa 2026-07-13 21:01:46 -04:00
parent a4e59040d6
commit d6404edefc
No known key found for this signature in database
11 changed files with 386 additions and 36 deletions

View file

@ -34,6 +34,8 @@ export interface ProposalListItem {
submittedAt: string; submittedAt: string;
submittedByName: string | null; submittedByName: string | null;
assignedAdminName: string | null; assignedAdminName: string | null;
/** Opaque optimistic-concurrency token (base64); echo back untouched. */
rowVersion: string;
} }
export interface ProposalDetail { export interface ProposalDetail {
@ -62,6 +64,8 @@ export interface ProposalDetail {
parentProposalId: string | null; parentProposalId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Opaque optimistic-concurrency token (base64); echo back untouched. */
rowVersion: string;
} }
export interface CreateProposalRequest { export interface CreateProposalRequest {
@ -81,6 +85,16 @@ export interface UpdateProposalRequest {
poNumber?: string; poNumber?: string;
workOrderNumber?: string; workOrderNumber?: string;
assignedAdminId?: string; assignedAdminId?: string;
/** Expected proposal rowVersion — REQUIRED by the server (422 ProposalVersionRequired when missing). */
proposalVersion?: string;
}
/** Body for the state-transition endpoints (approve, return-to-review, send,
* revise): the expected proposal rowVersion. The server rejects a missing
* token with 422 {code:"ProposalVersionRequired"} and a malformed one with
* 422 {code:"InvalidRowVersion"}. */
export interface ProposalVersionRequest {
proposalVersion?: string;
} }
export interface ProposalFilters { export interface ProposalFilters {
@ -116,6 +130,8 @@ export interface LineItem {
source: LineItemSource; source: LineItemSource;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Opaque optimistic-concurrency token (base64); echo back untouched. */
rowVersion: string;
} }
export interface CreateLineItemRequest { export interface CreateLineItemRequest {
@ -143,6 +159,8 @@ export interface UpdateLineItemEntry {
export interface BulkUpdateLineItemsRequest { export interface BulkUpdateLineItemsRequest {
lineItems: UpdateLineItemEntry[]; lineItems: UpdateLineItemEntry[];
/** Expected proposal rowVersion — the bulk replace is guarded by the PROPOSAL token. */
proposalVersion?: string;
} }
// ── Customers (CustomerDtos.cs) ─────────────────────────────────────────── // ── Customers (CustomerDtos.cs) ───────────────────────────────────────────
@ -285,3 +303,13 @@ export interface ApiProblem {
detail: string; detail: string;
code: ApiProblemCode; code: ApiProblemCode;
} }
// Optimistic-concurrency conflict envelope (SHOC ADR 0004 — NOT ProblemDetails):
// guarded writes that lose a race return 409 with the reloaded currentState so
// clients can refresh immediately. The unguarded-race safety net returns
// 409 { status: "Conflict", message, code: 409 } instead — same `message`
// field, no currentState.
export interface ConcurrencyConflict<T> {
message: string;
currentState: T | null;
}

View file

@ -14,6 +14,8 @@ import type {
ProposalDetail, ProposalDetail,
CreateProposalRequest, CreateProposalRequest,
UpdateProposalRequest, UpdateProposalRequest,
ProposalVersionRequest,
ConcurrencyConflict,
LineItem, LineItem,
CreateLineItemRequest, CreateLineItemRequest,
UpdateLineItemEntry, UpdateLineItemEntry,
@ -56,6 +58,7 @@ export const proposalListItemSchema = z.object({
submittedAt: z.string(), submittedAt: z.string(),
submittedByName: z.string().nullable(), submittedByName: z.string().nullable(),
assignedAdminName: z.string().nullable(), assignedAdminName: z.string().nullable(),
rowVersion: z.string(),
}) satisfies z.ZodType<ProposalListItem>; }) satisfies z.ZodType<ProposalListItem>;
export const proposalDetailSchema = z.object({ export const proposalDetailSchema = z.object({
@ -84,6 +87,7 @@ export const proposalDetailSchema = z.object({
parentProposalId: z.string().nullable(), parentProposalId: z.string().nullable(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
rowVersion: z.string(),
}) satisfies z.ZodType<ProposalDetail>; }) satisfies z.ZodType<ProposalDetail>;
export const createProposalRequestSchema = z.object({ export const createProposalRequestSchema = z.object({
@ -103,8 +107,13 @@ export const updateProposalRequestSchema = z.object({
poNumber: z.string().optional(), poNumber: z.string().optional(),
workOrderNumber: z.string().optional(), workOrderNumber: z.string().optional(),
assignedAdminId: z.string().optional(), assignedAdminId: z.string().optional(),
proposalVersion: z.string().optional(),
}) satisfies z.ZodType<UpdateProposalRequest>; }) satisfies z.ZodType<UpdateProposalRequest>;
export const proposalVersionRequestSchema = z.object({
proposalVersion: z.string().optional(),
}) satisfies z.ZodType<ProposalVersionRequest>;
export const proposalStatsSchema = z.object({ export const proposalStatsSchema = z.object({
totalCount: z.number(), totalCount: z.number(),
inReviewCount: z.number(), inReviewCount: z.number(),
@ -126,6 +135,7 @@ export const lineItemSchema = z.object({
source: lineItemSourceSchema, source: lineItemSourceSchema,
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
rowVersion: z.string(),
}) satisfies z.ZodType<LineItem>; }) satisfies z.ZodType<LineItem>;
export const createLineItemRequestSchema = z.object({ export const createLineItemRequestSchema = z.object({
@ -153,6 +163,7 @@ export const updateLineItemEntrySchema = z.object({
export const bulkUpdateLineItemsRequestSchema = z.object({ export const bulkUpdateLineItemsRequestSchema = z.object({
lineItems: z.array(updateLineItemEntrySchema), lineItems: z.array(updateLineItemEntrySchema),
proposalVersion: z.string().optional(),
}) satisfies z.ZodType<BulkUpdateLineItemsRequest>; }) satisfies z.ZodType<BulkUpdateLineItemsRequest>;
// ── Customers ───────────────────────────────────────────────────────────── // ── Customers ─────────────────────────────────────────────────────────────
@ -274,3 +285,11 @@ export const apiProblemSchema = z.object({
detail: z.string(), detail: z.string(),
code: z.string(), code: z.string(),
}) satisfies z.ZodType<ApiProblem>; }) satisfies z.ZodType<ApiProblem>;
// 409 concurrency-conflict envelope. The proposal aggregate is the only
// guarded resource today, so the concrete schema is coupled to ProposalDetail;
// build others via the same pattern when new aggregates gain guards.
export const proposalConcurrencyConflictSchema = z.object({
message: z.string(),
currentState: proposalDetailSchema.nullable(),
}) satisfies z.ZodType<ConcurrencyConflict<ProposalDetail>>;

View file

@ -38,6 +38,7 @@ const proposals = [
submittedAt: '2026-07-01T12:00:00Z', submittedAt: '2026-07-01T12:00:00Z',
submittedByName: 'Adam Moussa', submittedByName: 'Adam Moussa',
assignedAdminName: null, assignedAdminName: null,
rowVersion: 'AAAAAAAAAAE=',
}, },
{ {
id: 'p-2', id: 'p-2',
@ -51,6 +52,7 @@ const proposals = [
submittedAt: '2026-07-05T09:30:00Z', submittedAt: '2026-07-05T09:30:00Z',
submittedByName: 'Adam Moussa', submittedByName: 'Adam Moussa',
assignedAdminName: 'Sarah Chen', assignedAdminName: 'Sarah Chen',
rowVersion: 'AAAAAAAAAAI=',
}, },
]; ];

View file

@ -1,14 +1,20 @@
// Admin domain use-case hooks: state transitions must cross-domain // Admin domain use-case hooks: state transitions must cross-domain
// invalidate the proposals/lineItems keys (domain README rule 3). The domain // invalidate the proposals/lineItems keys (domain README rule 3), thread the
// api modules are mocked — no axios traffic. // optimistic-concurrency token from the cached detail into guarded mutations,
// and recover from 409 conflicts by refreshing from the server's currentState.
// The domain api modules are mocked — no axios traffic.
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { createQueryHarness } from './hookTestUtils'; import { createQueryHarness } from './hookTestUtils';
import { useApproveProposal } from '../admin/use-cases'; import { useApproveProposal, useSaveProposalWorkspace } from '../admin/use-cases';
import { proposalsKeys } from '../proposals/use-cases'; import { proposalsKeys } from '../proposals/use-cases';
import { lineItemsKeys } from '../lineItems/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases';
import { adminApi } from '../admin/api'; import { adminApi } from '../admin/api';
import { lineItemsApi } from '../lineItems/api';
import { proposalsApi } from '../proposals/api';
import { ConflictError } from '../../lib/api/errors';
import type { ProposalDetail } from '../proposals/types';
vi.mock('../admin/api', () => ({ vi.mock('../admin/api', () => ({
adminApi: { adminApi: {
@ -26,8 +32,8 @@ vi.mock('../admin/api', () => ({
}, },
})); }));
// useSaveProposalWorkspace pulls lineItemsApi directly; mock it so no test // useSaveProposalWorkspace pulls lineItemsApi and proposalsApi directly;
// path can reach axios. // mock both so no test path can reach axios.
vi.mock('../lineItems/api', () => ({ vi.mock('../lineItems/api', () => ({
lineItemsApi: { lineItemsApi: {
getAll: vi.fn(), getAll: vi.fn(),
@ -37,27 +43,51 @@ vi.mock('../lineItems/api', () => ({
}, },
})); }));
vi.mock('../proposals/api', () => ({
proposalsApi: {
create: vi.fn(),
getAll: vi.fn(),
getById: vi.fn(),
uploadAttachment: vi.fn(),
confirmUpload: vi.fn(),
getStats: vi.fn(),
getVendors: vi.fn(),
},
}));
vi.mock('react-toastify', () => ({ vi.mock('react-toastify', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
})); }));
const PROPOSAL_ID = 'p-7'; const PROPOSAL_ID = 'p-7';
const detail = (rowVersion: string): ProposalDetail =>
({ id: PROPOSAL_ID, rowVersion }) as unknown as ProposalDetail;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('useApproveProposal', () => { describe('useApproveProposal', () => {
it('invalidates the proposal detail and its line items keys and toasts on success', async () => { it('sends the cached rowVersion token, invalidates the proposal detail and its line items keys, and toasts on success', async () => {
vi.mocked(adminApi.approveProposal).mockResolvedValue(undefined); vi.mocked(adminApi.approveProposal).mockResolvedValue(detail('AAAAAAAAAAM='));
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
// The workspace always fetches the detail before actions are possible —
// the guarded mutation reads its token from this cache at mutate time.
queryClient.setQueryData(proposalsKeys.detail(PROPOSAL_ID), detail('AAAAAAAAAAI='));
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper });
result.current.mutate(); result.current.mutate();
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(adminApi.approveProposal).toHaveBeenCalledWith(PROPOSAL_ID); expect(adminApi.approveProposal).toHaveBeenCalledWith(PROPOSAL_ID, 'AAAAAAAAAAI=');
// The response carries the rotated token — it must land in the detail
// cache immediately so a follow-on guarded action doesn't race the
// invalidation refetch.
expect(queryClient.getQueryData(proposalsKeys.detail(PROPOSAL_ID))).toEqual(
detail('AAAAAAAAAAM='),
);
// Approval changes proposal status AND locks/reprices line items — both // Approval changes proposal status AND locks/reprices line items — both
// domains' keys must be refetched (cross-domain invalidation, rule 3). // domains' keys must be refetched (cross-domain invalidation, rule 3).
expect(invalidateSpy).toHaveBeenCalledWith({ expect(invalidateSpy).toHaveBeenCalledWith({
@ -94,4 +124,78 @@ describe('useApproveProposal', () => {
expect(invalidateSpy).not.toHaveBeenCalled(); expect(invalidateSpy).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled();
}); });
it('on 409 writes the server currentState into the detail cache, refreshes views, and toasts the conflict', async () => {
const currentState = detail('AAAAAAAAAAo=');
vi.mocked(adminApi.approveProposal).mockRejectedValue(
new ConflictError('Proposal was modified by another user.', currentState),
);
const { queryClient, wrapper } = createQueryHarness();
queryClient.setQueryData(proposalsKeys.detail(PROPOSAL_ID), detail('AAAAAAAAAAI='));
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper });
result.current.mutate();
await waitFor(() => expect(result.current.isError).toBe(true));
// Refresh-and-retry semantics: the stale detail is replaced by the
// server's reloaded state (fresh token included)...
expect(queryClient.getQueryData(proposalsKeys.detail(PROPOSAL_ID))).toEqual(currentState);
// ...every proposal view is refetched (stale-queue invariant holds even
// on the failure path)...
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.detail(PROPOSAL_ID) });
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.lists() });
// ...and the user gets the conflict message, not the generic failure toast.
expect(toast.warning).toHaveBeenCalledWith('Proposal was modified by another user.');
expect(toast.error).not.toHaveBeenCalled();
});
});
describe('useSaveProposalWorkspace', () => {
it('rotates the token across the guarded pair and refetches the detail for the fresh token', async () => {
vi.mocked(adminApi.updateProposal).mockResolvedValue(detail('AAAAAAAAAAM='));
vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]);
vi.mocked(proposalsApi.getById).mockResolvedValue(detail('AAAAAAAAAAQ='));
const { queryClient, wrapper } = createQueryHarness();
queryClient.setQueryData(proposalsKeys.detail(PROPOSAL_ID), detail('AAAAAAAAAAI='));
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
result.current.mutate({ refinedScope: 'refined', lineItems: [] });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// PUT sends the token the workspace loaded with...
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, {
refinedScope: 'refined',
proposalVersion: 'AAAAAAAAAAI=',
});
// ...the bulk replace sends the token the PUT rotated to...
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, [], 'AAAAAAAAAAM=');
// ...and the detail cache ends on the post-bulk token so approve-after-save
// never sends a stale version.
expect(queryClient.getQueryData(proposalsKeys.detail(PROPOSAL_ID))).toEqual(
detail('AAAAAAAAAAQ='),
);
expect(toast.success).toHaveBeenCalledWith('Changes saved');
});
it('on 409 refreshes from currentState and toasts the conflict instead of the save-failed toast', async () => {
const currentState = detail('AAAAAAAAAAo=');
vi.mocked(adminApi.updateProposal).mockRejectedValue(
new ConflictError('Proposal was modified by another user.', currentState),
);
const { queryClient, wrapper } = createQueryHarness();
queryClient.setQueryData(proposalsKeys.detail(PROPOSAL_ID), detail('AAAAAAAAAAI='));
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
result.current.mutate({ refinedScope: 'refined', lineItems: [] });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled();
expect(queryClient.getQueryData(proposalsKeys.detail(PROPOSAL_ID))).toEqual(currentState);
expect(toast.warning).toHaveBeenCalledWith('Proposal was modified by another user.');
expect(toast.error).not.toHaveBeenCalled();
});
}); });

View file

@ -11,7 +11,9 @@ import { useSaveProposalWorkspace } from '../admin/use-cases';
import { proposalsKeys } from '../proposals/use-cases'; import { proposalsKeys } from '../proposals/use-cases';
import { adminApi } from '../admin/api'; import { adminApi } from '../admin/api';
import { lineItemsApi } from '../lineItems/api'; import { lineItemsApi } from '../lineItems/api';
import { proposalsApi } from '../proposals/api';
import type { UpdateLineItemEntry } from '../lineItems/types'; import type { UpdateLineItemEntry } from '../lineItems/types';
import type { ProposalDetail } from '../proposals/types';
vi.mock('../admin/api', () => ({ vi.mock('../admin/api', () => ({
adminApi: { adminApi: {
@ -38,6 +40,19 @@ vi.mock('../lineItems/api', () => ({
}, },
})); }));
// The save flow ends with a detail refetch (post-bulk token rotation).
vi.mock('../proposals/api', () => ({
proposalsApi: {
create: vi.fn(),
getAll: vi.fn(),
getById: vi.fn(),
uploadAttachment: vi.fn(),
confirmUpload: vi.fn(),
getStats: vi.fn(),
getVendors: vi.fn(),
},
}));
vi.mock('react-toastify', () => ({ vi.mock('react-toastify', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
})); }));
@ -47,6 +62,9 @@ const entries = [
{ description: 'Labor', quantity: 2, unitPrice: 150 }, { description: 'Labor', quantity: 2, unitPrice: 150 },
] as unknown as UpdateLineItemEntry[]; ] as unknown as UpdateLineItemEntry[];
const detail = (rowVersion: string): ProposalDetail =>
({ id: PROPOSAL_ID, rowVersion }) as unknown as ProposalDetail;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -60,17 +78,24 @@ describe('lineItemsKeys', () => {
describe('useSaveProposalWorkspace', () => { describe('useSaveProposalWorkspace', () => {
it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => { it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => {
vi.mocked(adminApi.updateProposal).mockResolvedValue(undefined); vi.mocked(adminApi.updateProposal).mockResolvedValue(detail('v2'));
vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]); vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]);
vi.mocked(proposalsApi.getById).mockResolvedValue(detail('v3'));
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
queryClient.setQueryData(proposalsKeys.detail(PROPOSAL_ID), detail('v1'));
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
result.current.mutate({ refinedScope: 'refined', lineItems: entries }); result.current.mutate({ refinedScope: 'refined', lineItems: entries });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' }); // Token rotation: the PUT sends the cached token, the bulk replace sends
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); // the PUT response's rotated token.
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, {
refinedScope: 'refined',
proposalVersion: 'v1',
});
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries, 'v2');
expect(invalidateSpy).toHaveBeenCalledWith({ expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
}); });
@ -85,7 +110,7 @@ describe('useSaveProposalWorkspace', () => {
}); });
it('toasts a save failure and skips invalidation', async () => { it('toasts a save failure and skips invalidation', async () => {
vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict')); vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('validation failed'));
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@ -93,7 +118,7 @@ describe('useSaveProposalWorkspace', () => {
result.current.mutate({ refinedScope: 'refined', lineItems: entries }); result.current.mutate({ refinedScope: 'refined', lineItems: entries });
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); expect(toast.error).toHaveBeenCalledWith('Save failed: validation failed');
expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled(); expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled();
expect(invalidateSpy).not.toHaveBeenCalled(); expect(invalidateSpy).not.toHaveBeenCalled();
}); });

View file

@ -9,24 +9,34 @@ export const adminApi = {
return res.data; return res.data;
}, },
updateProposal: async (id: string, data: UpdateProposalRequest): Promise<void> => { // Guarded mutations (optimistic concurrency): every write below requires the
await apiClient.put(`/proposals/${id}`, data); // proposal's rowVersion token and returns the fresh proposal state — the
// server rotates the token on each guarded write, so callers chain from the
// RESPONSE's rowVersion, never the one they started with.
updateProposal: async (id: string, data: UpdateProposalRequest): Promise<ProposalDetail> => {
const res = await apiClient.put(`/proposals/${id}`, data);
return res.data;
}, },
approveProposal: async (id: string): Promise<void> => { approveProposal: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
await apiClient.post(`/proposals/${id}/approve`); const res = await apiClient.post(`/proposals/${id}/approve`, { proposalVersion });
return res.data;
}, },
returnToReview: async (id: string): Promise<void> => { returnToReview: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
await apiClient.post(`/proposals/${id}/return-to-review`); const res = await apiClient.post(`/proposals/${id}/return-to-review`, { proposalVersion });
return res.data;
}, },
sendProposal: async (id: string): Promise<void> => { sendProposal: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
await apiClient.post(`/proposals/${id}/send`); const res = await apiClient.post(`/proposals/${id}/send`, { proposalVersion });
return res.data;
}, },
reviseProposal: async (id: string): Promise<void> => { /** Returns the NEW revision (different id), not the revised original. */
await apiClient.post(`/proposals/${id}/revise`); reviseProposal: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
const res = await apiClient.post(`/proposals/${id}/revise`, { proposalVersion });
return res.data;
}, },
getHistory: async (id: string): Promise<ProposalDetail[]> => { getHistory: async (id: string): Promise<ProposalDetail[]> => {

View file

@ -8,9 +8,12 @@ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tansta
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { adminApi } from './api'; import { adminApi } from './api';
import { lineItemsApi } from '../lineItems/api'; import { lineItemsApi } from '../lineItems/api';
import { proposalsApi } from '../proposals/api';
import { proposalsKeys } from '../proposals/use-cases'; import { proposalsKeys } from '../proposals/use-cases';
import { lineItemsKeys } from '../lineItems/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases';
import { ConflictError } from '../../lib/api/errors';
import type { UpdateLineItemEntry } from '../lineItems/types'; import type { UpdateLineItemEntry } from '../lineItems/types';
import type { ProposalDetail } from '../proposals/types';
export const adminKeys = { export const adminKeys = {
all: ['admin'] as const, all: ['admin'] as const,
@ -33,6 +36,38 @@ function invalidateProposalViews(queryClient: QueryClient, proposalId: string) {
queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() }); queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() });
} }
/**
* The optimistic-concurrency token for a guarded mutation, read from the
* cached proposal detail at mutate time (the workspace always fetches the
* detail before any action is possible). Read lazily inside mutationFn —
* never captured at render — so a save-then-approve chain sees the token the
* save wrote back, not the one the page rendered with.
*/
function cachedProposalVersion(queryClient: QueryClient, proposalId: string): string | undefined {
return queryClient.getQueryData<ProposalDetail>(proposalsKeys.detail(proposalId))?.rowVersion;
}
/**
* 409 recovery (refresh-and-retry semantics): write the server's reloaded
* currentState into the detail cache (fresh token immediately available),
* refetch every proposal view, and tell the user their view was stale.
* Returns true when the error was a concurrency conflict — callers skip
* their generic failure toast in that case.
*/
function handleConcurrencyConflict(
queryClient: QueryClient,
proposalId: string,
error: Error,
): boolean {
if (!(error instanceof ConflictError)) return false;
if (error.currentState) {
queryClient.setQueryData(proposalsKeys.detail(proposalId), error.currentState);
}
invalidateProposalViews(queryClient, proposalId);
toast.warning(error.message);
return true;
}
/** Admin dashboard KPIs (AdminDashboard). */ /** Admin dashboard KPIs (AdminDashboard). */
export function useAdminDashboard() { export function useAdminDashboard() {
return useQuery({ return useQuery({
@ -63,14 +98,25 @@ export function useSaveProposalWorkspace(proposalId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => { mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => {
await adminApi.updateProposal(proposalId, { refinedScope }); // Guarded pair: the PUT rotates the proposal token, so the bulk replace
await lineItemsApi.bulkUpdate(proposalId, lineItems); // must send the PUT response's token, not the one the save started with.
const updated = await adminApi.updateProposal(proposalId, {
refinedScope,
proposalVersion: cachedProposalVersion(queryClient, proposalId),
});
await lineItemsApi.bulkUpdate(proposalId, lineItems, updated.rowVersion);
// The bulk replace rotates the token AGAIN but returns only line items —
// refetch the detail so a chained guarded action (approve-after-save)
// holds the current token instead of racing the invalidation refetch.
return proposalsApi.getById(proposalId);
}, },
onSuccess: () => { onSuccess: (fresh) => {
queryClient.setQueryData(proposalsKeys.detail(proposalId), fresh);
invalidateProposalViews(queryClient, proposalId); invalidateProposalViews(queryClient, proposalId);
toast.success('Changes saved'); toast.success('Changes saved');
}, },
onError: (error: Error) => { onError: (error: Error) => {
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Save failed: ${error.message}`); toast.error(`Save failed: ${error.message}`);
}, },
}); });
@ -79,12 +125,15 @@ export function useSaveProposalWorkspace(proposalId: string) {
export function useApproveProposal(proposalId: string) { export function useApproveProposal(proposalId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => adminApi.approveProposal(proposalId), mutationFn: () =>
onSuccess: () => { adminApi.approveProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (proposal) => {
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
invalidateProposalViews(queryClient, proposalId); invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal approved'); toast.success('Proposal approved');
}, },
onError: (error: Error) => { onError: (error: Error) => {
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Approval failed: ${error.message}`); toast.error(`Approval failed: ${error.message}`);
}, },
}); });
@ -93,14 +142,17 @@ export function useApproveProposal(proposalId: string) {
export function useSendProposal(proposalId: string) { export function useSendProposal(proposalId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => adminApi.sendProposal(proposalId), mutationFn: () =>
onSuccess: () => { adminApi.sendProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (proposal) => {
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
invalidateProposalViews(queryClient, proposalId); invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal marked as sent'); toast.success('Proposal marked as sent');
}, },
// Fix: WEB-H5 — mutation must surface failures to the user (relocated // Fix: WEB-H5 — mutation must surface failures to the user (relocated
// from AdminWorkspace during the domain-layer refactor) // from AdminWorkspace during the domain-layer refactor)
onError: (error: Error) => { onError: (error: Error) => {
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Send failed: ${error.message}`); toast.error(`Send failed: ${error.message}`);
}, },
}); });
@ -109,14 +161,19 @@ export function useSendProposal(proposalId: string) {
export function useReviseProposal(proposalId: string) { export function useReviseProposal(proposalId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => adminApi.reviseProposal(proposalId), mutationFn: () =>
onSuccess: () => { adminApi.reviseProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (revision) => {
// The response is the NEW revision (different id) — seed its detail
// cache; the invalidation below refreshes the original's views.
queryClient.setQueryData(proposalsKeys.detail(revision.id), revision);
invalidateProposalViews(queryClient, proposalId); invalidateProposalViews(queryClient, proposalId);
toast.success('Revision created'); toast.success('Revision created');
}, },
// Fix: WEB-H6 — mutation must surface failures to the user (relocated // Fix: WEB-H6 — mutation must surface failures to the user (relocated
// from AdminWorkspace during the domain-layer refactor) // from AdminWorkspace during the domain-layer refactor)
onError: (error: Error) => { onError: (error: Error) => {
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Revision failed: ${error.message}`); toast.error(`Revision failed: ${error.message}`);
}, },
}); });
@ -125,12 +182,15 @@ export function useReviseProposal(proposalId: string) {
export function useReturnToReview(proposalId: string) { export function useReturnToReview(proposalId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => adminApi.returnToReview(proposalId), mutationFn: () =>
onSuccess: () => { adminApi.returnToReview(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (proposal) => {
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
invalidateProposalViews(queryClient, proposalId); invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal returned to review'); toast.success('Proposal returned to review');
}, },
onError: (error: Error) => { onError: (error: Error) => {
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Return to review failed: ${error.message}`); toast.error(`Return to review failed: ${error.message}`);
}, },
}); });

View file

@ -13,8 +13,18 @@ export const lineItemsApi = {
return res.data; return res.data;
}, },
bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise<LineItem[]> => { /** Guarded by the PROPOSAL token: the wholesale replace conflicts with any
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems }); * concurrent change to the aggregate, so the server requires the proposal's
* current rowVersion. Create/delete stay unguarded (single-item ops). */
bulkUpdate: async (
proposalId: string,
lineItems: UpdateLineItemEntry[],
proposalVersion?: string,
): Promise<LineItem[]> => {
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, {
lineItems,
proposalVersion,
});
return res.data; return res.data;
}, },

View file

@ -7,11 +7,13 @@
* - Response interceptor clears the stored session and redirects on 401 * - Response interceptor clears the stored session and redirects on 401
* - Response interceptor returns friendly error message on 403 * - Response interceptor returns friendly error message on 403
* - Response interceptor returns friendly error message on 404 * - Response interceptor returns friendly error message on 404
* - Response interceptor throws ConflictError (with currentState) on 409
* - Response interceptor extracts server error detail from response body * - Response interceptor extracts server error detail from response body
* - Response interceptor handles network errors (no response) * - Response interceptor handles network errors (no response)
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import type { InternalAxiosRequestConfig } from 'axios'; import type { InternalAxiosRequestConfig } from 'axios';
import { ConflictError } from '../errors';
// vi.hoisted returns values accessible in both the hoisted mock scope and test scope. // vi.hoisted returns values accessible in both the hoisted mock scope and test scope.
const { interceptors } = vi.hoisted(() => { const { interceptors } = vi.hoisted(() => {
@ -209,6 +211,63 @@ describe('API client interceptors', () => {
await expect(promise).rejects.toThrow('The requested resource was not found.'); await expect(promise).rejects.toThrow('The requested resource was not found.');
}); });
it('409 with the guarded conflict envelope rejects with ConflictError carrying currentState', async () => {
const currentState = { id: 'p-1', status: 'Approved', rowVersion: 'AAAAAAAAAAM=' };
const error = {
response: {
status: 409,
data: { message: 'Proposal was modified by another user.', currentState },
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toMatchObject({
name: 'ConflictError',
message: 'Proposal was modified by another user.',
currentState,
});
await expect(promise).rejects.toBeInstanceOf(ConflictError);
});
it('409 with the unguarded fallback envelope rejects with ConflictError and null currentState', async () => {
const error = {
response: {
status: 409,
data: {
status: 'Conflict',
message: 'The record was modified by another user. Refresh and retry.',
code: 409,
},
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toBeInstanceOf(ConflictError);
await expect(promise).rejects.toMatchObject({
message: 'The record was modified by another user. Refresh and retry.',
currentState: null,
});
});
it('409 with an empty body falls back to a generic conflict message', async () => {
const error = {
response: { status: 409, data: {} },
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toBeInstanceOf(ConflictError);
await expect(promise).rejects.toMatchObject({
message: 'This record was modified by another user. Refresh and retry.',
currentState: null,
});
});
it('extracts detail message from server error response', async () => { it('extracts detail message from server error response', async () => {
const error = { const error = {
response: { response: {

View file

@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { API_URL, STORAGE_KEY_TOKEN } from '../../constants'; import { API_URL, STORAGE_KEY_TOKEN } from '../../constants';
import { AUTH_SESSION_CLEARED_EVENT, clearAuth } from '../auth/authStorage'; import { AUTH_SESSION_CLEARED_EVENT, clearAuth } from '../auth/authStorage';
import { ConflictError } from './errors';
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: API_URL, baseURL: API_URL,
@ -51,6 +52,19 @@ apiClient.interceptors.response.use(
return Promise.reject(new Error('The requested resource was not found.')); return Promise.reject(new Error('The requested resource was not found.'));
} }
if (status === 409) {
// Optimistic-concurrency conflict (SHOC ADR 0004): the guarded path
// carries { message, currentState }; the unguarded fallback carries
// { status, message, code } with no state. Throw a typed error so the
// domain layer can refresh from currentState instead of just toasting.
return Promise.reject(
new ConflictError(
data?.message || 'This record was modified by another user. Refresh and retry.',
data?.currentState ?? null,
),
);
}
const message = data?.detail || data?.title || data?.message || 'An error occurred'; const message = data?.detail || data?.title || data?.message || 'An error occurred';
return Promise.reject(new Error(message)); return Promise.reject(new Error(message));
} }

19
web/src/lib/api/errors.ts Normal file
View file

@ -0,0 +1,19 @@
// Typed transport errors thrown by the response interceptor in ./client.
import type { ConcurrencyConflict, ProposalDetail } from '@proposal-system/api-contracts';
/**
* HTTP 409 — optimistic-concurrency conflict (SHOC ADR 0004 envelope).
* Carries the server's reloaded `currentState` so callers can refresh their
* caches without a round trip. The proposal aggregate is the only guarded
* resource, so the state is typed to ProposalDetail; the unguarded-race
* fallback envelope has no state (`currentState` is null).
*/
export class ConflictError extends Error implements ConcurrencyConflict<ProposalDetail> {
readonly currentState: ProposalDetail | null;
constructor(message: string, currentState: ProposalDetail | null = null) {
super(message);
this.name = 'ConflictError';
this.currentState = currentState;
}
}