Files
PacketStation/frontend/src/components/useGuard.tsx
T
spencermandClaude Fable 5.1 37d9a0cb50 Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 09:31:45 -06:00

157 lines
4.7 KiB
TypeScript

import { useCallback, useMemo, useState, type ReactNode } from "react";
import { ApiError, errorCode } from "../api/client";
import type { Guarded, SignBody } from "../api/types";
import { ReasonSheet, type ReasonOptions } from "./ReasonSheet";
import { SignatureSheet } from "./SignatureSheet";
type Ask =
| { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void }
| {
kind: "sign";
meaning: string;
sub: string | undefined;
attempt: (sign: SignBody) => Promise<unknown>;
resolve: (r: { ok: true; value: unknown } | { ok: false }) => void;
reject: (err: unknown) => void;
};
export interface RunOptions {
/** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */
sign?: string | undefined;
/** Shown next to the sheet titles, like the job name. */
sub?: string | undefined;
}
export interface Guard {
/**
* Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`.
* On 400 signature_required it opens the signature sheet and retries with `sign`.
* Resolves undefined when the user cancels. Other errors are thrown.
*/
run: <T>(fn: (g: Guarded) => Promise<T>, opts?: RunOptions) => Promise<T | undefined>;
/** Asks for a reason. Null when cancelled. */
askReason: (opts: ReasonOptions) => Promise<string | null>;
/** The open sheet, if any. Render it once in the screen. */
sheet: ReactNode;
}
export const IT_REASON: ReasonOptions = {
title: "IT reason",
label: "Why IT is making this change",
note: "You are changing this as IT. The reason is recorded with the change.",
min: 8,
submitLabel: "Continue",
};
function meaningOf(err: unknown): string {
if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) {
const m = (err.body as { meaning: unknown }).meaning;
if (typeof m === "string" && m) return m;
}
return "Sign this step";
}
/** The IT reason and signature retries, as sheets. */
export function useGuard(): Guard {
const [ask, setAsk] = useState<Ask | null>(null);
const askReason = useCallback(
(opts: ReasonOptions) =>
new Promise<string | null>((resolve) => {
setAsk({ kind: "reason", opts, resolve });
}),
[],
);
const askSign = useCallback(
<T,>(meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise<T>) =>
new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => {
setAsk({
kind: "sign",
meaning,
sub,
attempt,
resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void,
reject,
});
}),
[],
);
const run = useCallback(
async <T,>(fn: (g: Guarded) => Promise<T>, opts: RunOptions = {}): Promise<T | undefined> => {
let itReason: string | undefined;
let meaning = opts.sign;
for (let round = 0; round < 4; round++) {
const call = (sign?: SignBody) => {
const g: Guarded = {};
if (itReason) g.it_reason = itReason;
if (sign) g.sign = sign;
return fn(g);
};
try {
if (meaning) {
const r = await askSign(meaning, opts.sub, call);
return r.ok ? r.value : undefined;
}
return await call();
} catch (err) {
const code = errorCode(err);
if (code === "it_reason_required" && !itReason) {
const r = await askReason({ ...IT_REASON, sub: opts.sub });
if (r === null) return undefined;
itReason = r;
continue;
}
if (code === "signature_required" && !meaning) {
meaning = meaningOf(err);
continue;
}
throw err;
}
}
return undefined;
},
[askReason, askSign],
);
let sheet: ReactNode = null;
if (ask?.kind === "reason") {
sheet = (
<ReasonSheet
{...ask.opts}
onSubmit={(r) => {
setAsk(null);
ask.resolve(r);
}}
onCancel={() => {
setAsk(null);
ask.resolve(null);
}}
/>
);
} else if (ask?.kind === "sign") {
sheet = (
<SignatureSheet
meaning={ask.meaning}
sub={ask.sub}
onSign={async (sign) => {
const value = await ask.attempt(sign);
setAsk(null);
ask.resolve({ ok: true, value });
}}
onFail={(err) => {
setAsk(null);
ask.reject(err);
}}
onCancel={() => {
setAsk(null);
ask.resolve({ ok: false });
}}
/>
);
}
return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]);
}