mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 13:58:56 +00:00
Gemini scanner sweep (token-bypass), GPT-4.1 cross-review, and the 6-detector /sh-security-review fan-out ran against 6acfdab..HEAD; every confirmed finding fixed: HIGH (deployment blockers, logic detector): - CONC-L1: suggestions lambda's bulk line-item PUT sent no proposalVersion — every AI suggestion job would 422 and be silently swallowed. Now fetches the proposal's rowVersion, echoes it, and retries once with a fresh token on 409. Pytest updated (38 green). - CONC-L2: mobile admin surface (update/approve/send/revise, bulk line items) sent no tokens — the entire mobile admin workflow would 422. Tokens threaded through mobile api layer + workspace/line-item screens with 409 refetch handling. tsc clean. MEDIUM-adjacent (scanner): - VendorProposalsController: the VendorTotalCost write on Proposal now bumps Version (was a silent lost-update path bypassing the guard). - FilesController: GeneratePDF audit staged into the same SaveChanges. LOW (detectors): - 409 envelope is schema-validated client-side (proposalConcurrencyConflictSchema.safeParse) and id-checked before seeding the react-query cache; malformed state degrades to invalidation (INJ-409-01/WEB-CONC-L1). - ProposalConcurrencyException.CurrentState typed ProposalResponse? so an EF entity can never serialize into the 409 body (SC-1). - Guard caller contract documented + GuardedEndpointAuthorizationTests reflection tripwire: guard-reaching endpoints must stay admin-gated (AUTHZ-CG-01). - Pre-check currentState now loads display navigations so both 409 paths return the same shape (CONC-L3). - Save chain's trailing getById failure no longer misreports a committed save; falls back to invalidation (CONC-L4). Also caught during fix verification: the handler's manual currentState serialization lacked JsonStringEnumConverter — enums would serialize as numbers, client schema validation would reject every guarded 409, and the state would always be discarded. Now matches the MVC pipeline and is pinned by a wire test. 193 xUnit / 70 vitest / 38 pytest green; mobile + shared tsc clean; Playwright smoke 2/2.
237 lines
9.5 KiB
TypeScript
237 lines
9.5 KiB
TypeScript
// Admin domain — TanStack Query hooks + query keys (the only public surface).
|
|
// State-transition mutations invalidate the proposals/lineItems domain keys
|
|
// (rule 3 — cross-domain invalidation via the sibling key objects) and carry
|
|
// the same toasts the AdminWorkspace page shows today. Page-specific side
|
|
// effects (dialog close, dirty reset, window.open, navigate) stay at the
|
|
// call site via the mutation's callbacks.
|
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
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,
|
|
dashboard: () => [...adminKeys.all, 'dashboard'] as const,
|
|
similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const,
|
|
};
|
|
|
|
/**
|
|
* Every state transition must refresh every view of the proposal: the
|
|
* workspace (detail + line items) AND the cached queue/list/stats/KPI
|
|
* queries — lists sit under the global 5-minute staleTime, so without the
|
|
* broad invalidation an admin returning to the queue after approving sees
|
|
* stale statuses.
|
|
*/
|
|
function invalidateProposalViews(queryClient: QueryClient, proposalId: string) {
|
|
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
|
queryClient.invalidateQueries({ queryKey: proposalsKeys.lists() });
|
|
queryClient.invalidateQueries({ queryKey: proposalsKeys.stats() });
|
|
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;
|
|
// Only seed the cache when the embedded state is actually this proposal —
|
|
// a mismatched or partial envelope falls through to invalidation, and the
|
|
// refetch restores truth.
|
|
if (error.currentState && error.currentState.id === proposalId) {
|
|
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({
|
|
queryKey: adminKeys.dashboard(),
|
|
queryFn: adminApi.getDashboard,
|
|
});
|
|
}
|
|
|
|
/** RAG similarity results (AdminWorkspace, SimilarProposalsPanel). */
|
|
export function useSimilarProposals(proposalId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: adminKeys.similar(proposalId ?? ''),
|
|
queryFn: () => adminApi.getSimilar(proposalId!),
|
|
enabled: !!proposalId,
|
|
});
|
|
}
|
|
|
|
export interface SaveWorkspaceVariables {
|
|
refinedScope: string;
|
|
lineItems: UpdateLineItemEntry[];
|
|
}
|
|
|
|
/**
|
|
* The workspace "Save" action: persist the refined scope, then replace the
|
|
* proposal's line items (mirrors AdminWorkspace saveMutation exactly).
|
|
*/
|
|
export function useSaveProposalWorkspace(proposalId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => {
|
|
// 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.
|
|
// Both writes are already committed here: if only this trailing GET
|
|
// fails, the save must still report success, with invalidation
|
|
// recovering the token instead (CONC-L4).
|
|
try {
|
|
return await proposalsApi.getById(proposalId);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
onSuccess: (fresh) => {
|
|
if (fresh) {
|
|
queryClient.setQueryData(proposalsKeys.detail(proposalId), fresh);
|
|
} else {
|
|
queryClient.removeQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
}
|
|
invalidateProposalViews(queryClient, proposalId);
|
|
toast.success('Changes saved');
|
|
},
|
|
onError: (error: Error) => {
|
|
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
|
|
toast.error(`Save failed: ${error.message}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useApproveProposal(proposalId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
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}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useSendProposal(proposalId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
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}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useReviseProposal(proposalId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
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}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useReturnToReview(proposalId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
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}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useGenerateSuggestions(proposalId: string) {
|
|
return useMutation({
|
|
mutationFn: () => adminApi.generateSuggestions(proposalId),
|
|
onSuccess: () => {
|
|
toast.info('AI suggestion generation started');
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(`Regeneration failed: ${error.message}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch (or queue generation of) the branded PDF. Resolves null while
|
|
* generation is queued (202). Success handling — open the download and
|
|
* toast — is page behavior and belongs at the call site.
|
|
*/
|
|
export function useProposalPdf(proposalId: string) {
|
|
return useMutation({
|
|
mutationFn: (regenerate?: boolean) => adminApi.getPdf(proposalId, regenerate ?? false),
|
|
onError: () => {
|
|
toast.error('Failed to generate PDF');
|
|
},
|
|
});
|
|
}
|