diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index d196f83..bd96f51 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -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 ); diff --git a/src/app/api/v1/quotes/[id]/pdf/route.ts b/src/app/api/v1/quotes/[id]/pdf/route.ts index a3e3329..9daabba 100644 --- a/src/app/api/v1/quotes/[id]/pdf/route.ts +++ b/src/app/api/v1/quotes/[id]/pdf/route.ts @@ -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 = `. 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") { diff --git a/src/lib/server/quote-pdf-service.test.ts b/src/lib/server/quote-pdf-service.test.ts index bacdcb5..e94c10f 100644 --- a/src/lib/server/quote-pdf-service.test.ts +++ b/src/lib/server/quote-pdf-service.test.ts @@ -180,6 +180,24 @@ const { pbMock, clearStore } = vi.hoisted(() => { const res = await this.getList(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( + filter: string, + opts?: { fields?: string; sort?: string }, + ) { + const res = await this.getList(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) { 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(); + }); +}); diff --git a/src/lib/server/quote-pdf-service.ts b/src/lib/server/quote-pdf-service.ts index 2b7b8ed..21e1c5a 100644 --- a/src/lib/server/quote-pdf-service.ts +++ b/src/lib/server/quote-pdf-service.ts @@ -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 { 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(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",