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:
parent
56476e3ca0
commit
48c57d47a7
4 changed files with 361 additions and 2 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue