feat(quotes): auto-claim unclaimed quotes on Generate PDF

Operators no longer need to click Claim before Generate. The PDF service stamps claimed_by = <caller> when actorEmail is provided, quote.claimed_by is empty, and the quote is in a non-terminal status. Auto-claim lands atomically with the status/timestamp flip. Skip cases (existing claim, terminal status, missing/invalid actor email) leave claimed_by alone and still render the PDF. Email resolution failures are logged and non-fatal. Surfaces the post-update claimed_by on the route outcome and process page setQuote so the Claimed-by chip refreshes without a refetch.
This commit is contained in:
Dave 2026-09-08 18:45:53 +02:00
parent 56476e3ca0
commit 48c57d47a7
4 changed files with 361 additions and 2 deletions

View file

@ -464,13 +464,23 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
attachmentId: data.attachmentId,
filename: data.filename,
});
// Update local status so the badge reflects the new state immediately.
// Update local status + claimed_by so the badge / "Claimed by"
// chip reflect the new state immediately. Auto-claim on the
// server may have written `claimed_by` when it was empty —
// the route surfaces the new value as `data.claimedBy` (null
// means "no claim"). Treat null and "" identically: the
// pre-existing `claimed_by` is left alone in that case so a
// stale local copy doesn't get clobbered.
setQuote((prev) =>
prev
? {
...prev,
status: data.status,
pdf_generated_at: data.pdfGeneratedAt || prev.pdf_generated_at,
claimed_by:
typeof data.claimedBy === "string" && data.claimedBy !== ""
? data.claimedBy
: prev.claimed_by,
}
: prev
);

View file

@ -5,6 +5,7 @@ import {
type QuotePdfReadinessIssue,
} from "@/lib/server/quote-pdf-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getServerIdentity } from "@/lib/dev-identity";
import {
PLACEHOLDER_CUSTOMER_IDS,
PLACEHOLDER_CUSTOMER_NAMES,
@ -86,6 +87,16 @@ export async function POST(
);
}
// Auto-claim: pass the caller's dev identity through to the
// service so a successful PDF render on an unclaimed quote
// stamps `claimed_by = <caller>`. The service handles the skip
// cases (terminal status, already claimed, email does not
// resolve). Identity comes from the same `getServerIdentity`
// helper the review-approve route uses — header > cookie >
// env > default — so the operator switcher in the UI is the
// authoritative source of "who clicked the button".
const actorEmail = getServerIdentity(req);
// The service does the actual work; we just map the outcome to
// the existing HTTP shape so the route's wire contract is
// unchanged. `letterhead` is validated inside the service
@ -95,6 +106,7 @@ export async function POST(
// `letterhead: "CNL"` for an ISHERWOOD quote doesn't 500.
const outcome = await generateQuotePdf(quoteId, {
letterhead: body.letterhead,
actorEmail,
});
return renderOutcomeToResponse(outcome);
@ -118,6 +130,12 @@ function renderOutcomeToResponse(outcome: GenerateQuotePdfOutcome) {
readyItemCount: outcome.readyItemCount,
totalItemCount: outcome.totalItemCount,
isPartial: outcome.isPartial,
// Surface the post-update `claimed_by` so the process page
// can refresh its local copy after auto-claim without a
// full refetch. `null` means "no claim" (terminal status,
// already claimed by someone, or actor email didn't
// resolve); the UI treats null and "" the same.
claimedBy: outcome.claimedBy,
});
}
if (outcome.kind === "readiness_failure") {

View file

@ -180,6 +180,24 @@ const { pbMock, clearStore } = vi.hoisted(() => {
const res = await this.getList<T>(1, opts?.batch ?? 500, opts);
return res.items;
},
// PB throws a 404 when no row matches the filter; service
// callers wrap this in try/catch (see auto-claim lookup in
// quote-pdf-service). Tests assert on the rejection path
// the same way the real PB behaves.
async getFirstListItem<T>(
filter: string,
opts?: { fields?: string; sort?: string },
) {
const res = await this.getList<T>(1, 1, { filter, ...opts });
if (res.items.length === 0) {
const err = new Error(
`404: ${name} not found (filter=${filter})`,
) as Error & { status: number };
err.status = 404;
throw err;
}
return res.items[0];
},
async create(data: Record<string, unknown>) {
const id = nextId();
const now = new Date().toISOString();
@ -585,3 +603,240 @@ describe("generateQuotePdf", () => {
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// generateQuotePdf — auto-claim (Option 1)
//
// The service stamps `claimed_by` on the quote when:
// - `actorEmail` is provided,
// - the quote's `claimed_by` is currently empty,
// - the quote is NOT in a terminal status,
// - the email resolves to a PB user row.
//
// These tests pin every branch in the skip-condition matrix so we
// don't accidentally regress to "always claim" or "never claim".
// ─────────────────────────────────────────────────────────────────────────────
describe("generateQuotePdf auto-claim", () => {
/**
* Create a PB user row so `getFirstListItem('email = "..."')`
* resolves. The auto-claim path only needs `id` + `email`.
*/
async function seedUser(email: string, id = `usr-${email}`) {
return pbMock.collection("users").create({ email, name: email });
// Override `id` so the test can pin a known value if it
// matters to the assertion.
void id;
}
it("auto-claims the quote to the resolved user on the status-flip path", async () => {
const user = await seedUser("david.els1@outlook.com");
const { quote } = await seedQuoteWithPdf();
// Quote starts with `claimed_by = ""` (PB default for missing
// string fields). Seed an unpriced item so we don't take the
// no-items branch.
await pbMock.collection("quote_items").create({
quote: quote.id,
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
moved_to_quote: null,
});
const outcome = await generateQuotePdf(quote.id, {
letterhead: "CNL",
actorEmail: "david.els1@outlook.com",
});
expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBe(user.id);
// The claim must land atomically with the status flip in the
// SAME PB write — verify the post-update quote has both.
const after = await pbMock.collection("quotes").getOne(quote.id);
expect(after.status).toBe("approval_pending");
expect(after.claimed_by).toBe(user.id);
});
it("auto-claims on the idempotent-regen path (approval_pending)", async () => {
const user = await seedUser("david.els1@outlook.com");
const { quote } = await seedQuoteWithPdf();
// seedQuoteWithPdf already sets status="approval_pending".
// Flip it back to empty `claimed_by` so the auto-claim branch
// fires (simulates the first PDF run after the auto-claim
// contract landed, on a quote whose claim was never written).
await pbMock.collection("quotes").update(quote.id, { claimed_by: "" });
await pbMock.collection("quote_items").create({
quote: quote.id,
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
moved_to_quote: null,
});
const outcome = await generateQuotePdf(quote.id, {
letterhead: "CNL",
actorEmail: "david.els1@outlook.com",
});
expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBe(user.id);
const after = await pbMock.collection("quotes").getOne(quote.id);
// Status stays put on the regen path; claim lands alongside.
expect(after.status).toBe("approval_pending");
expect(after.claimed_by).toBe(user.id);
});
it("does NOT overwrite an existing claim (no reclaim)", async () => {
// Two users — one already on the claim, one is the caller.
// The caller must lose; auto-claim is opt-in only.
await pbMock.collection("users").create({
email: "existing@wfdash.local",
name: "Existing Owner",
});
const caller = await pbMock.collection("users").create({
email: "caller@wfdash.local",
name: "Caller",
});
const { quote } = await seedQuoteWithPdf();
await pbMock.collection("quotes").update(quote.id, {
claimed_by: "existing-user-id",
status: "claimed",
});
await pbMock.collection("quote_items").create({
quote: quote.id,
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
moved_to_quote: null,
});
const outcome = await generateQuotePdf(quote.id, {
letterhead: "CNL",
actorEmail: "caller@wfdash.local",
});
expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
// Outcome surfaces the (unchanged) claim.
expect(outcome.claimedBy).toBe("existing-user-id");
// Caller's id is not written; the pre-existing claim wins.
const after = await pbMock.collection("quotes").getOne(quote.id);
expect(after.claimed_by).toBe("existing-user-id");
expect(after.claimed_by).not.toBe(caller.id);
});
it("does NOT auto-claim on a terminal status (emailed)", async () => {
const user = await seedUser("david.els1@outlook.com");
const { quote } = await seedQuoteWithPdf();
// Simulate a quote that has already been emailed to the
// customer. claimed_by is empty (legacy import) — but the
// service must still refuse to claim because the work is done.
await pbMock.collection("quotes").update(quote.id, {
status: "emailed",
claimed_by: "",
});
await pbMock.collection("quote_items").create({
quote: quote.id,
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
moved_to_quote: null,
});
const outcome = await generateQuotePdf(quote.id, {
letterhead: "CNL",
actorEmail: "david.els1@outlook.com",
});
expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
// claimedBy is null (no claim) — the terminal-status skip wins
// over the actorEmail resolution.
expect(outcome.claimedBy).toBeNull();
const after = await pbMock.collection("quotes").getOne(quote.id);
expect(after.claimed_by).toBeFalsy();
// Status is also left alone (terminal path skips the timestamp
// bump entirely — see comment in service).
expect(after.status).toBe("emailed");
void user;
});
it("does NOT auto-claim when actorEmail does not resolve to a user", async () => {
const { quote } = await seedQuoteWithPdf();
await pbMock.collection("quote_items").create({
quote: quote.id,
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
moved_to_quote: null,
});
// No user seeded — getFirstListItem will throw 404, the
// service catches and logs a warning, and the PDF still
// renders. This pins the "non-fatal" behaviour so we don't
// silently break the PDF flow if PB's users collection is
// temporarily empty.
const outcome = await generateQuotePdf(quote.id, {
letterhead: "CNL",
actorEmail: "nobody@wfdash.local",
});
expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBeNull();
const after = await pbMock.collection("quotes").getOne(quote.id);
expect(after.claimed_by).toBeFalsy();
// Status still flipped — the PDF render is the whole point.
expect(after.status).toBe("approval_pending");
});
it("does NOT auto-claim when no actorEmail is provided (internal callers)", async () => {
// The send-email pipeline calls generateQuotePdf without an
// actorEmail — verify it doesn't surprise-write a claim.
const { quote } = await seedQuoteWithPdf();
await pbMock.collection("quote_items").create({
quote: quote.id,
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
moved_to_quote: null,
});
const outcome = await generateQuotePdf(quote.id, {
letterhead: "CNL",
// no actorEmail — same shape the send-email pipeline uses
});
expect(outcome.kind).toBe("ok");
if (outcome.kind !== "ok") throw new Error("expected ok outcome");
expect(outcome.claimedBy).toBeNull();
const after = await pbMock.collection("quotes").getOne(quote.id);
expect(after.claimed_by).toBeFalsy();
});
});

View file

@ -137,6 +137,16 @@ export type GenerateQuotePdfOutcome =
readyItemCount: number;
totalItemCount: number;
isPartial: boolean;
/**
* The `claimed_by` value AFTER the render completed. Equals the
* prior `quote.claimed_by` unless this run auto-claimed the
* quote to the caller (then it's the caller's PB user id).
* Surfaced so the route can write it back to the response and
* the process page can keep its local copy in sync without a
* full refetch. `null` if the quote stayed unclaimed (terminal
* status, existing claim, or actor email did not resolve).
*/
claimedBy: string | null;
}
| {
kind: "readiness_failure";
@ -171,10 +181,21 @@ export type GenerateQuotePdfOutcome =
* @param quoteId PB id of the quote to render
* @param options.letterhead Optional override; defaults to the quote's
* `company_letterhead` (CNL for uninitialised rows)
* @param options.actorEmail Optional identity of the caller. When
* provided AND the quote's `claimed_by` is empty AND the quote is
* in a non-terminal status, the service resolves this email to a
* PB `users` row and writes its id onto `claimed_by` in the same
* update that flips status / bumps `pdf_generated_at`. This is
* the "auto-claim on Generate PDF" affordance — operators no
* longer need to click Claim before Generate. Auto-claim does
* NOT overwrite an existing `claimed_by` (no reclaim) and does
* NOT fire on terminal statuses (work already done). Email
* resolution failures are non-fatal: the PDF still renders and
* `claimed_by` simply stays empty.
*/
export async function generateQuotePdf(
quoteId: string,
options?: { letterhead?: string },
options?: { letterhead?: string; actorEmail?: string },
): Promise<GenerateQuotePdfOutcome> {
const pb = await getAdminPocketBase();
@ -219,6 +240,44 @@ export async function generateQuotePdf(
? (requestedCode as LetterheadCode)
: ("CNL" as LetterheadCode);
// ── 2b. Auto-claim resolution ────────────────────────────
// Resolve `actorEmail` (from the route's `getServerIdentity`) to
// a PB `users` id, ONCE, only when the conditions below are
// satisfied. The result (`autoClaimUserId`) is threaded into the
// status-update branch below so the auto-claim lands atomically
// in the same PB write that flips status / bumps the timestamp.
//
// Skip conditions — keep the same rules as the legacy
// `handleAction("claim")` flow:
// - `claimed_by` already set → "no reclaim" per Option 1 spec
// - terminal status (`approved`/`emailed`/`complete`/
// `cancelled`/`sending`) → work is done, claim would be
// misleading
// - no `actorEmail` (e.g. internal / MCP call with no
// request context) → nothing to claim to
// - email doesn't resolve to a PB user row → skip silently,
// log + carry on. The PDF still renders.
let autoClaimUserId: string | null = null;
if (options?.actorEmail && !quote.claimed_by && !TERMINAL_STATUSES.has(quote.status)) {
try {
const actor = await pb
.collection("users")
.getFirstListItem<{ id: string }>(
`email = "${options.actorEmail.replace(/"/g, '\\"')}"`,
{ fields: "id" },
);
autoClaimUserId = actor.id;
} catch (err) {
// Non-fatal — auto-claim is best-effort. The render still
// proceeds; the operator will see the unclaimed badge and
// can claim manually.
console.warn(
`[quote-pdf-service] auto-claim lookup failed for ${options.actorEmail}:`,
err instanceof Error ? err.message : err,
);
}
}
// ── 3. Customer readiness gate ───────────────────────────
// Block on placeholder customer. The office has to pick a
// real one on the process page before they can generate a
@ -362,6 +421,11 @@ export async function generateQuotePdf(
.create<QuoteAttachment>(form);
// ── 7. Status flip / timestamp bump ──────────────────────
// If we resolved an `autoClaimUserId` above, include it in
// the patch so the claim lands atomically with the status
// flip / timestamp bump — one PB write, no race window where
// another client could observe a "claimed but no claimer"
// state. Both update branches below honour `autoClaimUserId`.
let newStatus = quote.status;
let pdfGeneratedAt: string | null = quote.pdf_generated_at || null;
if (
@ -377,6 +441,7 @@ export async function generateQuotePdf(
pdfGeneratedAt = new Date().toISOString();
await pb.collection("quotes").update(quoteId, {
pdf_generated_at: pdfGeneratedAt,
...(autoClaimUserId ? { claimed_by: autoClaimUserId } : {}),
});
}
} else {
@ -388,10 +453,18 @@ export async function generateQuotePdf(
await pb.collection("quotes").update(quoteId, {
status: targetStatus,
pdf_generated_at: pdfGeneratedAt,
...(autoClaimUserId ? { claimed_by: autoClaimUserId } : {}),
});
newStatus = targetStatus;
}
// Compute the post-update claimed_by for the outcome. If we
// auto-claimed this run, that's the new value; otherwise the
// claim is whatever was already on the quote (which may be
// empty). Surfacing it lets the route write the response and
// the process page keep its local copy in sync.
const claimedBy = autoClaimUserId ?? (quote.claimed_by || null);
return {
kind: "ok",
attachmentId: saved.id,
@ -402,8 +475,11 @@ export async function generateQuotePdf(
readyItemCount: ready.length,
totalItemCount: allItems.length,
isPartial: !!partialNote,
claimedBy,
};
} catch (err) {
// eslint-disable-next-line no-console
console.error("[DEBUG quote-pdf-service] catch:", err);
return {
kind: "render_error",
message: "PDF render failed",