mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 04:42:00 +00:00
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:
parent
a4e59040d6
commit
d6404edefc
11 changed files with 386 additions and 36 deletions
|
|
@ -34,6 +34,8 @@ export interface ProposalListItem {
|
|||
submittedAt: string;
|
||||
submittedByName: string | null;
|
||||
assignedAdminName: string | null;
|
||||
/** Opaque optimistic-concurrency token (base64); echo back untouched. */
|
||||
rowVersion: string;
|
||||
}
|
||||
|
||||
export interface ProposalDetail {
|
||||
|
|
@ -62,6 +64,8 @@ export interface ProposalDetail {
|
|||
parentProposalId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Opaque optimistic-concurrency token (base64); echo back untouched. */
|
||||
rowVersion: string;
|
||||
}
|
||||
|
||||
export interface CreateProposalRequest {
|
||||
|
|
@ -81,6 +85,16 @@ export interface UpdateProposalRequest {
|
|||
poNumber?: string;
|
||||
workOrderNumber?: 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 {
|
||||
|
|
@ -116,6 +130,8 @@ export interface LineItem {
|
|||
source: LineItemSource;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Opaque optimistic-concurrency token (base64); echo back untouched. */
|
||||
rowVersion: string;
|
||||
}
|
||||
|
||||
export interface CreateLineItemRequest {
|
||||
|
|
@ -143,6 +159,8 @@ export interface UpdateLineItemEntry {
|
|||
|
||||
export interface BulkUpdateLineItemsRequest {
|
||||
lineItems: UpdateLineItemEntry[];
|
||||
/** Expected proposal rowVersion — the bulk replace is guarded by the PROPOSAL token. */
|
||||
proposalVersion?: string;
|
||||
}
|
||||
|
||||
// ── Customers (CustomerDtos.cs) ───────────────────────────────────────────
|
||||
|
|
@ -285,3 +303,13 @@ export interface ApiProblem {
|
|||
detail: string;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import type {
|
|||
ProposalDetail,
|
||||
CreateProposalRequest,
|
||||
UpdateProposalRequest,
|
||||
ProposalVersionRequest,
|
||||
ConcurrencyConflict,
|
||||
LineItem,
|
||||
CreateLineItemRequest,
|
||||
UpdateLineItemEntry,
|
||||
|
|
@ -56,6 +58,7 @@ export const proposalListItemSchema = z.object({
|
|||
submittedAt: z.string(),
|
||||
submittedByName: z.string().nullable(),
|
||||
assignedAdminName: z.string().nullable(),
|
||||
rowVersion: z.string(),
|
||||
}) satisfies z.ZodType<ProposalListItem>;
|
||||
|
||||
export const proposalDetailSchema = z.object({
|
||||
|
|
@ -84,6 +87,7 @@ export const proposalDetailSchema = z.object({
|
|||
parentProposalId: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
rowVersion: z.string(),
|
||||
}) satisfies z.ZodType<ProposalDetail>;
|
||||
|
||||
export const createProposalRequestSchema = z.object({
|
||||
|
|
@ -103,8 +107,13 @@ export const updateProposalRequestSchema = z.object({
|
|||
poNumber: z.string().optional(),
|
||||
workOrderNumber: z.string().optional(),
|
||||
assignedAdminId: z.string().optional(),
|
||||
proposalVersion: z.string().optional(),
|
||||
}) satisfies z.ZodType<UpdateProposalRequest>;
|
||||
|
||||
export const proposalVersionRequestSchema = z.object({
|
||||
proposalVersion: z.string().optional(),
|
||||
}) satisfies z.ZodType<ProposalVersionRequest>;
|
||||
|
||||
export const proposalStatsSchema = z.object({
|
||||
totalCount: z.number(),
|
||||
inReviewCount: z.number(),
|
||||
|
|
@ -126,6 +135,7 @@ export const lineItemSchema = z.object({
|
|||
source: lineItemSourceSchema,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
rowVersion: z.string(),
|
||||
}) satisfies z.ZodType<LineItem>;
|
||||
|
||||
export const createLineItemRequestSchema = z.object({
|
||||
|
|
@ -153,6 +163,7 @@ export const updateLineItemEntrySchema = z.object({
|
|||
|
||||
export const bulkUpdateLineItemsRequestSchema = z.object({
|
||||
lineItems: z.array(updateLineItemEntrySchema),
|
||||
proposalVersion: z.string().optional(),
|
||||
}) satisfies z.ZodType<BulkUpdateLineItemsRequest>;
|
||||
|
||||
// ── Customers ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -274,3 +285,11 @@ export const apiProblemSchema = z.object({
|
|||
detail: z.string(),
|
||||
code: z.string(),
|
||||
}) 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>>;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const proposals = [
|
|||
submittedAt: '2026-07-01T12:00:00Z',
|
||||
submittedByName: 'Adam Moussa',
|
||||
assignedAdminName: null,
|
||||
rowVersion: 'AAAAAAAAAAE=',
|
||||
},
|
||||
{
|
||||
id: 'p-2',
|
||||
|
|
@ -51,6 +52,7 @@ const proposals = [
|
|||
submittedAt: '2026-07-05T09:30:00Z',
|
||||
submittedByName: 'Adam Moussa',
|
||||
assignedAdminName: 'Sarah Chen',
|
||||
rowVersion: 'AAAAAAAAAAI=',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
// Admin domain use-case hooks: state transitions must cross-domain
|
||||
// invalidate the proposals/lineItems keys (domain README rule 3). The domain
|
||||
// api modules are mocked — no axios traffic.
|
||||
// invalidate the proposals/lineItems keys (domain README rule 3), thread the
|
||||
// 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 { renderHook, waitFor } from '@testing-library/react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { createQueryHarness } from './hookTestUtils';
|
||||
import { useApproveProposal } from '../admin/use-cases';
|
||||
import { useApproveProposal, useSaveProposalWorkspace } from '../admin/use-cases';
|
||||
import { proposalsKeys } from '../proposals/use-cases';
|
||||
import { lineItemsKeys } from '../lineItems/use-cases';
|
||||
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', () => ({
|
||||
adminApi: {
|
||||
|
|
@ -26,8 +32,8 @@ vi.mock('../admin/api', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
// useSaveProposalWorkspace pulls lineItemsApi directly; mock it so no test
|
||||
// path can reach axios.
|
||||
// useSaveProposalWorkspace pulls lineItemsApi and proposalsApi directly;
|
||||
// mock both so no test path can reach axios.
|
||||
vi.mock('../lineItems/api', () => ({
|
||||
lineItemsApi: {
|
||||
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', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const PROPOSAL_ID = 'p-7';
|
||||
|
||||
const detail = (rowVersion: string): ProposalDetail =>
|
||||
({ id: PROPOSAL_ID, rowVersion }) as unknown as ProposalDetail;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useApproveProposal', () => {
|
||||
it('invalidates the proposal detail and its line items keys and toasts on success', async () => {
|
||||
vi.mocked(adminApi.approveProposal).mockResolvedValue(undefined);
|
||||
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(detail('AAAAAAAAAAM='));
|
||||
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 { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper });
|
||||
result.current.mutate();
|
||||
|
||||
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
|
||||
// domains' keys must be refetched (cross-domain invalidation, rule 3).
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
|
|
@ -94,4 +124,78 @@ describe('useApproveProposal', () => {
|
|||
expect(invalidateSpy).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import { useSaveProposalWorkspace } from '../admin/use-cases';
|
|||
import { proposalsKeys } from '../proposals/use-cases';
|
||||
import { adminApi } from '../admin/api';
|
||||
import { lineItemsApi } from '../lineItems/api';
|
||||
import { proposalsApi } from '../proposals/api';
|
||||
import type { UpdateLineItemEntry } from '../lineItems/types';
|
||||
import type { ProposalDetail } from '../proposals/types';
|
||||
|
||||
vi.mock('../admin/api', () => ({
|
||||
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', () => ({
|
||||
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 },
|
||||
] as unknown as UpdateLineItemEntry[];
|
||||
|
||||
const detail = (rowVersion: string): ProposalDetail =>
|
||||
({ id: PROPOSAL_ID, rowVersion }) as unknown as ProposalDetail;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
|
@ -60,17 +78,24 @@ describe('lineItemsKeys', () => {
|
|||
|
||||
describe('useSaveProposalWorkspace', () => {
|
||||
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(proposalsApi.getById).mockResolvedValue(detail('v3'));
|
||||
const { queryClient, wrapper } = createQueryHarness();
|
||||
queryClient.setQueryData(proposalsKeys.detail(PROPOSAL_ID), detail('v1'));
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
|
||||
result.current.mutate({ refinedScope: 'refined', lineItems: entries });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' });
|
||||
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries);
|
||||
// Token rotation: the PUT sends the cached token, the bulk replace sends
|
||||
// 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({
|
||||
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
|
||||
});
|
||||
|
|
@ -85,7 +110,7 @@ describe('useSaveProposalWorkspace', () => {
|
|||
});
|
||||
|
||||
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 invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
|
|
@ -93,7 +118,7 @@ describe('useSaveProposalWorkspace', () => {
|
|||
result.current.mutate({ refinedScope: 'refined', lineItems: entries });
|
||||
|
||||
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(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,24 +9,34 @@ export const adminApi = {
|
|||
return res.data;
|
||||
},
|
||||
|
||||
updateProposal: async (id: string, data: UpdateProposalRequest): Promise<void> => {
|
||||
await apiClient.put(`/proposals/${id}`, data);
|
||||
// Guarded mutations (optimistic concurrency): every write below requires the
|
||||
// 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> => {
|
||||
await apiClient.post(`/proposals/${id}/approve`);
|
||||
approveProposal: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
|
||||
const res = await apiClient.post(`/proposals/${id}/approve`, { proposalVersion });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
returnToReview: async (id: string): Promise<void> => {
|
||||
await apiClient.post(`/proposals/${id}/return-to-review`);
|
||||
returnToReview: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
|
||||
const res = await apiClient.post(`/proposals/${id}/return-to-review`, { proposalVersion });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
sendProposal: async (id: string): Promise<void> => {
|
||||
await apiClient.post(`/proposals/${id}/send`);
|
||||
sendProposal: async (id: string, proposalVersion?: string): Promise<ProposalDetail> => {
|
||||
const res = await apiClient.post(`/proposals/${id}/send`, { proposalVersion });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
reviseProposal: async (id: string): Promise<void> => {
|
||||
await apiClient.post(`/proposals/${id}/revise`);
|
||||
/** Returns the NEW revision (different id), not the revised original. */
|
||||
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[]> => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tansta
|
|||
import { toast } from 'react-toastify';
|
||||
import { adminApi } from './api';
|
||||
import { lineItemsApi } from '../lineItems/api';
|
||||
import { proposalsApi } from '../proposals/api';
|
||||
import { proposalsKeys } from '../proposals/use-cases';
|
||||
import { lineItemsKeys } from '../lineItems/use-cases';
|
||||
import { ConflictError } from '../../lib/api/errors';
|
||||
import type { UpdateLineItemEntry } from '../lineItems/types';
|
||||
import type { ProposalDetail } from '../proposals/types';
|
||||
|
||||
export const adminKeys = {
|
||||
all: ['admin'] as const,
|
||||
|
|
@ -33,6 +36,38 @@ function invalidateProposalViews(queryClient: QueryClient, proposalId: string) {
|
|||
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). */
|
||||
export function useAdminDashboard() {
|
||||
return useQuery({
|
||||
|
|
@ -63,14 +98,25 @@ export function useSaveProposalWorkspace(proposalId: string) {
|
|||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => {
|
||||
await adminApi.updateProposal(proposalId, { refinedScope });
|
||||
await lineItemsApi.bulkUpdate(proposalId, lineItems);
|
||||
// Guarded pair: the PUT rotates the proposal token, so the bulk replace
|
||||
// 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);
|
||||
toast.success('Changes saved');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
|
||||
toast.error(`Save failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
|
@ -79,12 +125,15 @@ export function useSaveProposalWorkspace(proposalId: string) {
|
|||
export function useApproveProposal(proposalId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => adminApi.approveProposal(proposalId),
|
||||
onSuccess: () => {
|
||||
mutationFn: () =>
|
||||
adminApi.approveProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
|
||||
onSuccess: (proposal) => {
|
||||
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
|
||||
invalidateProposalViews(queryClient, proposalId);
|
||||
toast.success('Proposal approved');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
|
||||
toast.error(`Approval failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
|
@ -93,14 +142,17 @@ export function useApproveProposal(proposalId: string) {
|
|||
export function useSendProposal(proposalId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => adminApi.sendProposal(proposalId),
|
||||
onSuccess: () => {
|
||||
mutationFn: () =>
|
||||
adminApi.sendProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
|
||||
onSuccess: (proposal) => {
|
||||
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
|
||||
invalidateProposalViews(queryClient, proposalId);
|
||||
toast.success('Proposal marked as sent');
|
||||
},
|
||||
// Fix: WEB-H5 — mutation must surface failures to the user (relocated
|
||||
// from AdminWorkspace during the domain-layer refactor)
|
||||
onError: (error: Error) => {
|
||||
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
|
||||
toast.error(`Send failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
|
@ -109,14 +161,19 @@ export function useSendProposal(proposalId: string) {
|
|||
export function useReviseProposal(proposalId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => adminApi.reviseProposal(proposalId),
|
||||
onSuccess: () => {
|
||||
mutationFn: () =>
|
||||
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);
|
||||
toast.success('Revision created');
|
||||
},
|
||||
// Fix: WEB-H6 — mutation must surface failures to the user (relocated
|
||||
// from AdminWorkspace during the domain-layer refactor)
|
||||
onError: (error: Error) => {
|
||||
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
|
||||
toast.error(`Revision failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
|
@ -125,12 +182,15 @@ export function useReviseProposal(proposalId: string) {
|
|||
export function useReturnToReview(proposalId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => adminApi.returnToReview(proposalId),
|
||||
onSuccess: () => {
|
||||
mutationFn: () =>
|
||||
adminApi.returnToReview(proposalId, cachedProposalVersion(queryClient, proposalId)),
|
||||
onSuccess: (proposal) => {
|
||||
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
|
||||
invalidateProposalViews(queryClient, proposalId);
|
||||
toast.success('Proposal returned to review');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
|
||||
toast.error(`Return to review failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,8 +13,18 @@ export const lineItemsApi = {
|
|||
return res.data;
|
||||
},
|
||||
|
||||
bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise<LineItem[]> => {
|
||||
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems });
|
||||
/** Guarded by the PROPOSAL token: the wholesale replace conflicts with any
|
||||
* 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;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@
|
|||
* - 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 404
|
||||
* - Response interceptor throws ConflictError (with currentState) on 409
|
||||
* - Response interceptor extracts server error detail from response body
|
||||
* - Response interceptor handles network errors (no response)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
import type { InternalAxiosRequestConfig } from 'axios';
|
||||
import { ConflictError } from '../errors';
|
||||
|
||||
// vi.hoisted returns values accessible in both the hoisted mock scope and test scope.
|
||||
const { interceptors } = vi.hoisted(() => {
|
||||
|
|
@ -209,6 +211,63 @@ describe('API client interceptors', () => {
|
|||
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 () => {
|
||||
const error = {
|
||||
response: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import axios from 'axios';
|
||||
import { API_URL, STORAGE_KEY_TOKEN } from '../../constants';
|
||||
import { AUTH_SESSION_CLEARED_EVENT, clearAuth } from '../auth/authStorage';
|
||||
import { ConflictError } from './errors';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
|
|
@ -51,6 +52,19 @@ apiClient.interceptors.response.use(
|
|||
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';
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
|
|
|
|||
19
web/src/lib/api/errors.ts
Normal file
19
web/src/lib/api/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue