This commit includes the temporary Next.js build cache files, public brand logos, and project configuration added in this change.
1395 lines
54 KiB
TypeScript
1395 lines
54 KiB
TypeScript
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
import type { Quote, Customer, Company } from "@/lib/types";
|
|
|
|
/**
|
|
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
|
|
*
|
|
* This module is the canonical home for all email-to-quote business logic.
|
|
* It deliberately avoids the pitfalls of the old Django implementation:
|
|
*
|
|
* - No fuzzy customer/company matching — only exact + normalized.
|
|
* - No fabricated cost or selling prices on AI-extracted items.
|
|
* - No racy `last + 1` quote number generation — uses a PocketBase-side
|
|
* atomic counter.
|
|
* - Replies are written to the `quote_replies` collection, never to a
|
|
* text blob on `quotes.notes`.
|
|
* - Idempotency: the same email (by Message-ID) cannot create two quotes.
|
|
*/
|
|
|
|
const QUOTES_COLLECTION = "quotes";
|
|
const QUOTE_ITEMS_COLLECTION = "quote_items";
|
|
const QUOTE_ATTACHMENTS_COLLECTION = "quote_attachments";
|
|
const QUOTE_REPLIES_COLLECTION = "quote_replies";
|
|
const QUOTE_COUNTERS_COLLECTION = "quote_counters";
|
|
const CUSTOMERS_COLLECTION = "customers";
|
|
const COMPANIES_COLLECTION = "companies";
|
|
|
|
const QUOTE_NUMBER_REGEX = /(?:Re:\s*)?(?:Quotation|Quote)\s+(Q-?\d{4}-?\d{2}-?\d{3})/i;
|
|
|
|
/**
|
|
* Sentinel customer for emails with no From: header. PB enforces
|
|
* `required=true` on `quotes.customer`, so we can't pass null. Instead,
|
|
* every email intake with an empty `from_email` is linked to this single
|
|
* stable customer. The quoter reassigns in the quote-editing UI if/when
|
|
* they know who the real sender is.
|
|
*
|
|
* The email domain `no-from.wfdash.local` is impossible to confuse with a
|
|
* real address; the description makes the role explicit.
|
|
*/
|
|
const UNKNOWN_SENDER_CUSTOMER_EMAIL = "unknown-sender@no-from.wfdash.local";
|
|
const UNKNOWN_SENDER_CUSTOMER_NAME = "Unknown Sender";
|
|
const UNKNOWN_SENDER_CUSTOMER_DESCRIPTION =
|
|
"Auto-created sentinel for emails with no From: header. The quoter reassigns in the quote-editing UI.";
|
|
|
|
export interface EmailIntakeAttachment {
|
|
filename: string;
|
|
content_type?: string;
|
|
size?: number;
|
|
/** Raw bytes encoded as base64 (no `data:` URL prefix). */
|
|
base64: string;
|
|
}
|
|
|
|
export interface EmailIntakeExtractedItem {
|
|
description?: string;
|
|
quantity?: number;
|
|
unit?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface EmailIntakePayload {
|
|
from_email: string;
|
|
from_name?: string;
|
|
subject: string;
|
|
body_plain?: string;
|
|
body_html?: string;
|
|
/** RFC 5322 Message-ID. Required for dedup. */
|
|
message_id: string;
|
|
in_reply_to?: string;
|
|
received_at?: string;
|
|
extracted_items?: EmailIntakeExtractedItem[];
|
|
attachments?: EmailIntakeAttachment[];
|
|
/** Optional override for company matching — used by the AI extraction layer. */
|
|
company_hint?: string;
|
|
}
|
|
|
|
export interface EmailReplyPayload {
|
|
from_email: string;
|
|
from_name?: string;
|
|
subject: string;
|
|
body_plain?: string;
|
|
body_html?: string;
|
|
message_id?: string;
|
|
in_reply_to?: string;
|
|
received_at?: string;
|
|
quoted_text?: string;
|
|
}
|
|
|
|
export interface CreateQuoteFromEmailResult {
|
|
quote: Quote;
|
|
customer: Customer;
|
|
company: Company | null;
|
|
items: number;
|
|
attachments: number;
|
|
deduped: boolean;
|
|
quote_number: string;
|
|
}
|
|
|
|
export interface AppendReplyResult {
|
|
quote: Quote | null;
|
|
reply: Record<string, unknown> | null;
|
|
matched_by: "in_reply_to" | "subject" | "none";
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Atomic quote number generation
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generates the next quote number (`QYYYYMMNNN`) atomically.
|
|
*
|
|
* Uses the `quote_counters` collection with a unique `(year, month)` index.
|
|
* The `last_seq+` increment operator is evaluated server-side by PocketBase
|
|
* against the current row state, so concurrent callers cannot collide even
|
|
* if they read the counter at the same instant.
|
|
*/
|
|
export async function generateQuoteNumberServer(): Promise<string> {
|
|
const pb = await getAdminPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = now.getMonth() + 1;
|
|
|
|
const counter = await getOrCreateCounter(pb, year, month);
|
|
const updated = await pb.collection(QUOTE_COUNTERS_COLLECTION).update(counter.id, {
|
|
"last_seq+": 1,
|
|
});
|
|
|
|
return formatQuoteNumber(year, month, updated.last_seq);
|
|
}
|
|
|
|
async function getOrCreateCounter(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
year: number,
|
|
month: number
|
|
) {
|
|
try {
|
|
return await pb
|
|
.collection(QUOTE_COUNTERS_COLLECTION)
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
} catch (err) {
|
|
// First request for this month — backfill the counter from any
|
|
// pre-existing quotes in that month, then create it. Without the
|
|
// backfill, the new code would start at 001 and collide with
|
|
// existing quotes. The `last_seq` we seed with is the max sequence
|
|
// for the (year, month) pair; the next `last_seq+` increment lands
|
|
// on the correct next number. The migration
|
|
// `2026-07-10-restore-counters.ts` also backfills — keeping the
|
|
// logic in both places is a safety belt: the migration runs once
|
|
// for an admin-initiated recovery, and this self-heals in the
|
|
// background if the collection is ever wiped while the service
|
|
// is live.
|
|
const backfill = await getMaxQuoteSequenceInMonth(pb, year, month);
|
|
try {
|
|
return await pb.collection(QUOTE_COUNTERS_COLLECTION).create({
|
|
year,
|
|
month,
|
|
last_seq: backfill,
|
|
});
|
|
} catch {
|
|
// Concurrent caller beat us to it — re-fetch the row they made.
|
|
return await pb
|
|
.collection(QUOTE_COUNTERS_COLLECTION)
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the highest sequence number found in `quotes.quote_number`
|
|
* for the given (year, month) — or 0 if no quotes exist for that
|
|
* month. Used to seed the counter when it's first created for a
|
|
* (year, month) pair, so the generator continues from the existing
|
|
* sequence rather than resetting to 001.
|
|
*
|
|
* Accepts the dashless `QYYYYMMNNN` format (current). Legacy dashed
|
|
* `Q-YYYY-MM-NNN` rows are also parsed — they have a different regex
|
|
* so both are handled in one pass.
|
|
*/
|
|
async function getMaxQuoteSequenceInMonth(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
year: number,
|
|
month: number
|
|
): Promise<number> {
|
|
const mm = String(month).padStart(2, "0");
|
|
const dashlessPrefix = `Q${year}${mm}`;
|
|
const dashedPrefix = `Q-${year}-${mm}-`;
|
|
try {
|
|
const all = await pb.collection(QUOTES_COLLECTION).getFullList<{
|
|
quote_number?: string;
|
|
}>({
|
|
filter: `quote_number ~ "${dashlessPrefix}" || quote_number ~ "${dashedPrefix}"`,
|
|
});
|
|
let max = 0;
|
|
// Two regexes — one for the no-dash format (the current contract),
|
|
// one for the legacy dashed format that some old rows still carry.
|
|
const reNoDash = new RegExp(`^Q${year}${mm}(\\d+)$`);
|
|
const reDashed = new RegExp(`^Q-${year}-${mm}-(\\d+)$`);
|
|
for (const q of all) {
|
|
const num = q.quote_number || "";
|
|
const m = num.match(reNoDash) || num.match(reDashed);
|
|
if (m) {
|
|
const n = parseInt(m[1], 10);
|
|
if (Number.isFinite(n) && n > max) max = n;
|
|
}
|
|
}
|
|
return max;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function formatQuoteNumber(year: number, month: number, seq: number): string {
|
|
return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Customer matching
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Finds an existing customer by exact email match, or creates a new one.
|
|
*
|
|
* Looks up `email`, then `alternative_email`, then `buyer_email` — case
|
|
* insensitive but otherwise an exact equality match. If no match, creates a
|
|
* new customer with a `description` flag marking it as auto-created from
|
|
* email intake (for audit).
|
|
*
|
|
* Never fuzzy-merges. Two customers with the same domain are kept separate
|
|
* until a human reviews them.
|
|
*/
|
|
export async function findOrCreateCustomer(input: {
|
|
email: string;
|
|
name?: string;
|
|
companyHint?: string;
|
|
}): Promise<{ customer: Customer; created: boolean }> {
|
|
const pb = await getAdminPocketBase();
|
|
const email = (input.email || "").trim().toLowerCase();
|
|
if (!email) {
|
|
throw new Error("Customer email is required for findOrCreateCustomer.");
|
|
}
|
|
|
|
const exact = await findCustomerByExactEmail(pb, email);
|
|
if (exact) {
|
|
return { customer: exact, created: false };
|
|
}
|
|
|
|
const senderDomain = email.split("@")[1] || "";
|
|
const fallbackName = input.name?.trim() || senderDomain || email;
|
|
// Resolve the company hint to a relation id, not a free-text name —
|
|
// `customer.company` is now a PB relation (2026-06-28 schema change).
|
|
// If no matching company exists, the link is left empty for the
|
|
// operator to fill in via the customer form.
|
|
const hintName = input.companyHint?.trim() || senderDomain;
|
|
let companyId = "";
|
|
if (hintName) {
|
|
const match =
|
|
(await findCompanyByCaseInsensitiveExact(pb, hintName)) ||
|
|
(await findCompanyByNormalized(pb, hintName));
|
|
if (match) companyId = match.id;
|
|
}
|
|
|
|
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
|
|
customer: fallbackName,
|
|
email,
|
|
company: companyId,
|
|
dateadded: new Date().toISOString().slice(0, 10),
|
|
description: "Auto-created from email intake.",
|
|
});
|
|
return { customer: created as Customer, created: true };
|
|
}
|
|
|
|
/**
|
|
* Returns the singleton "Unknown Sender" customer used when an inbound email
|
|
* has no From: header. Created on first use, reused for all subsequent
|
|
* unknown-sender emails. The race-safety pattern (try-create, re-fetch on
|
|
* unique-index failure) mirrors `generateQuoteNumberServer`.
|
|
*/
|
|
async function findOrCreateUnknownSenderCustomer(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>
|
|
): Promise<{ customer: Customer; created: boolean }> {
|
|
const existing = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL);
|
|
if (existing) {
|
|
return { customer: existing, created: false };
|
|
}
|
|
try {
|
|
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
|
|
customer: UNKNOWN_SENDER_CUSTOMER_NAME,
|
|
email: UNKNOWN_SENDER_CUSTOMER_EMAIL,
|
|
// Link to a literal "Unknown" company record if one exists;
|
|
// otherwise leave the relation empty. The UI surfaces a warning
|
|
// when this customer has no company link.
|
|
company: await resolveCompanyId(pb, "Unknown"),
|
|
dateadded: new Date().toISOString().slice(0, 10),
|
|
description: UNKNOWN_SENDER_CUSTOMER_DESCRIPTION,
|
|
});
|
|
return { customer: created as Customer, created: true };
|
|
} catch (err) {
|
|
// Race: a concurrent intake created the sentinel first. Re-fetch.
|
|
const refetched = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL);
|
|
if (refetched) {
|
|
return { customer: refetched, created: false };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a company name to a relation id. Empty string when no
|
|
* matching company record exists.
|
|
*/
|
|
async function resolveCompanyId(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
name: string
|
|
): Promise<string> {
|
|
const match =
|
|
(await findCompanyByCaseInsensitiveExact(pb, name)) ||
|
|
(await findCompanyByNormalized(pb, name));
|
|
return match?.id || "";
|
|
}
|
|
|
|
async function findCustomerByExactEmail(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
email: string
|
|
): Promise<Customer | null> {
|
|
const esc = email.replace(/"/g, '\\"');
|
|
// Try the primary email field first, then the two alternates. Each is a
|
|
// separate query — cheap because of PocketBase's text index.
|
|
for (const field of ["email", "alternative_email", "buyer_email"] as const) {
|
|
try {
|
|
const hit = await pb.collection(CUSTOMERS_COLLECTION).getFirstListItem(`${field} = "${esc}"`);
|
|
if (hit) return hit as Customer;
|
|
} catch {
|
|
// 404 — keep looking.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Company matching
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Finds an existing company by normalized name, or creates a new one.
|
|
*
|
|
* Two-step match:
|
|
* 1. Case-insensitive exact match on `company` field.
|
|
* 2. Normalized match (lowercase, trim, strip common suffixes) on `company`.
|
|
*
|
|
* If neither matches, creates a new record. Never fuzzy-merges.
|
|
*/
|
|
export async function findOrCreateCompany(input: {
|
|
name?: string;
|
|
address?: string;
|
|
}): Promise<{ company: Company | null; created: boolean }> {
|
|
const pb = await getAdminPocketBase();
|
|
const name = (input.name || "").trim();
|
|
if (!name) {
|
|
return { company: null, created: false };
|
|
}
|
|
|
|
const exactHit = await findCompanyByCaseInsensitiveExact(pb, name);
|
|
if (exactHit) return { company: exactHit, created: false };
|
|
|
|
const normalizedHit = await findCompanyByNormalized(pb, name);
|
|
if (normalizedHit) return { company: normalizedHit, created: false };
|
|
|
|
const created = await pb.collection(COMPANIES_COLLECTION).create({
|
|
company: name,
|
|
address: input.address || "",
|
|
vendor: "",
|
|
});
|
|
return { company: created as Company, created: true };
|
|
}
|
|
|
|
async function findCompanyByCaseInsensitiveExact(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
name: string
|
|
): Promise<Company | null> {
|
|
try {
|
|
const hit = await pb
|
|
.collection(COMPANIES_COLLECTION)
|
|
.getFirstListItem(`company = "${name.replace(/"/g, '\\"')}"`);
|
|
return hit as Company;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function findCompanyByNormalized(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
name: string
|
|
): Promise<Company | null> {
|
|
const normalized = normalizeCompanyName(name);
|
|
if (!normalized) return null;
|
|
const all = await pb.collection(COMPANIES_COLLECTION).getFullList<Company>({ fields: "id,company" });
|
|
for (const c of all) {
|
|
if (normalizeCompanyName(c.company) === normalized) {
|
|
return c;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Normalizes a company name for comparison: lowercase, trim, collapse spaces,
|
|
* strip common legal suffixes, strip trailing punctuation. Intentionally
|
|
* conservative — only strips things that are unambiguously noise.
|
|
*/
|
|
export function normalizeCompanyName(input: string): string {
|
|
return (input || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.replace(/[.,]+$/g, "")
|
|
.replace(
|
|
/\b(pty ltd|proprietary limited|ltd|limited|inc|incorporated|llc|corp|corporation|holdings|group|co\.?za|company)\b\.?/gi,
|
|
""
|
|
)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Quote creation from email
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Creates a Quote (and customer, company, items, attachments) from a parsed
|
|
* email payload. Idempotent on `message_id`.
|
|
*
|
|
* The human-opt-in pattern: AI-extracted items are created with
|
|
* `included_in_quote = false` and all prices zero. A human must review and
|
|
* opt each line in via the existing quote processing UI. We never fabricate
|
|
* cost or selling prices.
|
|
*/
|
|
export async function createQuoteFromEmail(
|
|
payload: EmailIntakePayload
|
|
): Promise<CreateQuoteFromEmailResult> {
|
|
// The n8n Parse Email node stamps `(no subject)` / `(no body)` placeholders
|
|
// when an inbound email is missing those headers (a malformed / RFC 5321-
|
|
// violating send). We accept whatever string the caller sends, including
|
|
// the placeholder, rather than 500ing — the office can then see the
|
|
// placeholder quote in the queue and triage manually. The type contract
|
|
// still says `subject: string`, and the n8n side is now contractually
|
|
// obligated to always send a non-empty string.
|
|
|
|
// Some senders (notably iCloud/Mail.app) strip the RFC 5322 Message-ID
|
|
// header. We still need a stable dedup key, so synthesize one from the
|
|
// real email identity. The `synth-` prefix and `@wfdash.local` suffix
|
|
// make it visually obvious in PocketBase that this isn't a real header.
|
|
const effectiveMessageId = payload.message_id?.trim() || synthesizeMessageId(payload);
|
|
|
|
// Some senders also strip the From: header entirely. PB enforces
|
|
// `required=true` on `quotes.customer`, so an empty `from_email` cannot
|
|
// be passed through as `null`. We compute a single `fromEmail` value and
|
|
// fall back to a sentinel "Unknown Sender" customer at the resolution
|
|
// step below. The `email_sender` field on the quote stores the literal
|
|
// empty string, not a placeholder — the sentinel is only used for the
|
|
// relation.
|
|
const fromEmail = (payload.from_email || "").trim().toLowerCase();
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
// ── 1. Idempotency: if we already have a quote for this Message-ID, return it.
|
|
const existing = await findQuoteByMessageId(pb, effectiveMessageId);
|
|
if (existing) {
|
|
const itemsCount = await countQuoteItems(pb, existing.id);
|
|
const attsCount = await countQuoteAttachments(pb, existing.id);
|
|
const customer = existing.customer
|
|
? ((await pb.collection(CUSTOMERS_COLLECTION).getOne(existing.customer)) as Customer)
|
|
: null;
|
|
const company = existing.company
|
|
? ((await pb.collection(COMPANIES_COLLECTION).getOne(existing.company)) as Company)
|
|
: null;
|
|
return {
|
|
quote: existing,
|
|
customer: customer as Customer,
|
|
company: company as Company | null,
|
|
items: itemsCount,
|
|
attachments: attsCount,
|
|
deduped: true,
|
|
quote_number: existing.quote_number,
|
|
};
|
|
}
|
|
|
|
// ── 2. Resolve or create the customer. Empty from_email → sentinel.
|
|
const { customer } = fromEmail
|
|
? await findOrCreateCustomer({
|
|
email: fromEmail,
|
|
name: payload.from_name,
|
|
companyHint: payload.company_hint,
|
|
})
|
|
: await findOrCreateUnknownSenderCustomer(pb);
|
|
|
|
// ── 3. Resolve or create the company. Only the AI's `company_hint` is
|
|
// trusted — the subject is never a fallback, because unparseable emails
|
|
// would otherwise create junk `companies` rows. The quoter picks the
|
|
// company in the form when the AI couldn't find one.
|
|
const { company } = await findOrCreateCompany({
|
|
name: payload.company_hint,
|
|
});
|
|
|
|
// ── 4. Generate a quote number.
|
|
const quoteNumber = await generateQuoteNumberServer();
|
|
|
|
// ── 5. Create the quote.
|
|
const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date();
|
|
const quote = await pb.collection(QUOTES_COLLECTION).create({
|
|
quote_number: quoteNumber,
|
|
customer: customer.id,
|
|
company: company?.id || null,
|
|
description: payload.subject,
|
|
status: "new",
|
|
notes: "",
|
|
email_sender: fromEmail,
|
|
email_subject: payload.subject,
|
|
email_body: payload.body_plain || payload.body_html || "",
|
|
has_attachments: (payload.attachments?.length ?? 0) > 0,
|
|
email_message_id: effectiveMessageId,
|
|
company_letterhead: "CNL",
|
|
});
|
|
|
|
// ── 6. Pre-populate quote items from the AI extraction. Human opt-in only.
|
|
const items = (payload.extracted_items || []).filter((i) => (i.description || "").trim());
|
|
for (const item of items) {
|
|
await pb.collection(QUOTE_ITEMS_COLLECTION).create({
|
|
quote: quote.id,
|
|
description: item.description,
|
|
quote_number: quoteNumber,
|
|
quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1,
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
notes: item.notes || "",
|
|
is_processed: false,
|
|
});
|
|
}
|
|
|
|
// ── 7. Attachments are uploaded in a follow-up step, NOT here.
|
|
//
|
|
// The previous design embedded all attachments in the single
|
|
// `quotes.create_from_email` POST as base64 in the JSON body. A single
|
|
// 5 MB PDF became ~6.7 MB of base64 in one HTTP request, which the
|
|
// Next.js route handler rejected at its default 1 MB body limit, so
|
|
// the whole create call failed and no quote row was written.
|
|
//
|
|
// The n8n pipeline now calls `quotes.create_from_email` (which creates
|
|
// the quote row + items) and then loops over the attachments, calling
|
|
// `attachments.upload_for_quote` once per file with a small per-call
|
|
// body. Failures are isolated per attachment and don't roll back the
|
|
// quote.
|
|
//
|
|
// We deliberately accept `attachments` in the payload for back-compat
|
|
// with any caller that still sends them, but we no longer act on them
|
|
// here. The result's `attachments` count is always 0 from this path.
|
|
|
|
return {
|
|
quote: quote as Quote,
|
|
customer,
|
|
company: company ?? null,
|
|
items: items.length,
|
|
attachments: 0,
|
|
deduped: false,
|
|
quote_number: quoteNumber,
|
|
};
|
|
}
|
|
|
|
async function uploadQuoteAttachment(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
input: {
|
|
quoteId: string;
|
|
filename: string;
|
|
contentType?: string;
|
|
size?: number;
|
|
base64: string;
|
|
messageId: string;
|
|
}
|
|
): Promise<Record<string, unknown>> {
|
|
const buffer = Buffer.from(input.base64, "base64");
|
|
const file = new File([buffer], input.filename, {
|
|
type: input.contentType || "application/octet-stream",
|
|
});
|
|
// The `quote_attachments.uploaded_at` date field is declared as
|
|
// onCreate-auto in `docs/pocketbase/quote_attachments.collection.json`,
|
|
// but the live PB collection in production (set up before the docs
|
|
// schema was finalised) doesn't carry that auto-fill behaviour, and
|
|
// PB rejects the create with `validation_required` if we don't send a
|
|
// value. We send `new Date().toISO()` so the field is populated for
|
|
// both schemas; PB ignores it on the doc schema and stores it on the
|
|
// live one. Cheap and forward-compatible.
|
|
const created = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).create({
|
|
quote: input.quoteId,
|
|
filename: input.filename,
|
|
file,
|
|
content_type: input.contentType || "application/octet-stream",
|
|
size_bytes: input.size ?? buffer.length,
|
|
email_message_id: input.messageId,
|
|
kind: classifyAttachment(input.contentType, input.filename),
|
|
uploaded_at: new Date().toISOString(),
|
|
});
|
|
|
|
// Flip the parent quote's `has_attachments` flag so the office sees
|
|
// the "📎" badge on the quote list. We update unconditionally rather
|
|
// than reading first — `has_attachments` is a bool column, so
|
|
// setting it to `true` on every call is idempotent and avoids a
|
|
// round-trip per attachment.
|
|
try {
|
|
await pb.collection("quotes").update(input.quoteId, { has_attachments: true });
|
|
} catch (e) {
|
|
// Non-fatal: the attachment row is the source of truth, and the
|
|
// flag will get re-synced on the next upload. Log but don't throw.
|
|
console.warn(
|
|
`[uploadQuoteAttachment] failed to set has_attachments=true on quote ${input.quoteId}:`,
|
|
(e as Error)?.message || e
|
|
);
|
|
}
|
|
|
|
return created as Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Public entry point used by the `attachments.upload_for_quote` MCP tool.
|
|
* Uploads a single attachment to the `quote_attachments` collection, linked
|
|
* to an existing quote. The caller (n8n) is expected to call this once per
|
|
* attachment after `quotes.create_from_email` returns the new quote id.
|
|
*
|
|
* Errors propagate (the MCP tool surfaces them) so the office sees which
|
|
* files didn't make it. A failed attachment does NOT roll back the quote —
|
|
* the quote is more valuable than a single bad file, and a quoter can
|
|
* always re-upload the missing file via a future API.
|
|
*/
|
|
export async function uploadAttachmentForQuote(input: {
|
|
quoteId: string;
|
|
filename: string;
|
|
contentType?: string;
|
|
size?: number;
|
|
base64: string;
|
|
messageId?: string;
|
|
}): Promise<Record<string, unknown>> {
|
|
if (!input.quoteId) throw new Error("quoteId is required.");
|
|
if (!input.filename) throw new Error("filename is required.");
|
|
if (!input.base64) throw new Error("base64 is required.");
|
|
const pb = await getAdminPocketBase();
|
|
return uploadQuoteAttachment(pb, {
|
|
quoteId: input.quoteId,
|
|
filename: input.filename,
|
|
contentType: input.contentType,
|
|
size: input.size,
|
|
base64: input.base64,
|
|
messageId: input.messageId || "",
|
|
});
|
|
}
|
|
|
|
function classifyAttachment(contentType: string | undefined, filename: string): string {
|
|
const ct = (contentType || "").toLowerCase();
|
|
const fn = (filename || "").toLowerCase();
|
|
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
|
|
if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp)$/.test(fn)) return "image";
|
|
if (
|
|
ct.includes("officedocument") ||
|
|
ct.includes("msword") ||
|
|
ct.includes("ms-excel") ||
|
|
/\.(docx?|xlsx?)$/.test(fn)
|
|
) {
|
|
return "doc";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
async function countQuoteItems(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
quoteId: string
|
|
): Promise<number> {
|
|
const list = await pb.collection(QUOTE_ITEMS_COLLECTION).getList(1, 1, {
|
|
filter: `quote = "${quoteId}"`,
|
|
});
|
|
return list.totalItems;
|
|
}
|
|
|
|
async function countQuoteAttachments(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
quoteId: string
|
|
): Promise<number> {
|
|
const list = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).getList(1, 1, {
|
|
filter: `quote = "${quoteId}"`,
|
|
});
|
|
return list.totalItems;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Reply handling
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Appends a reply to an existing quote. Match priority:
|
|
* 1. `In-Reply-To` header → look up `quotes.email_message_id`
|
|
* 2. `QYYYYMMNNN` extracted from subject → look up `quotes.quote_number`
|
|
* 3. Otherwise returns `{ matched_by: 'none' }` so the caller can move the
|
|
* email to the Unmatched folder.
|
|
*
|
|
* The reply is stored as a `quote_replies` row. We never mutate
|
|
* `quotes.notes` — the human-readable thread is joined at render time.
|
|
*/
|
|
export async function appendReplyToQuote(
|
|
payload: EmailReplyPayload
|
|
): Promise<AppendReplyResult> {
|
|
const pb = await getAdminPocketBase();
|
|
const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date();
|
|
|
|
const { quote, matched_by } = await resolveReplyTarget(pb, payload);
|
|
|
|
if (!quote) {
|
|
return { quote: null, reply: null, matched_by: "none" };
|
|
}
|
|
|
|
const reply = await pb.collection(QUOTE_REPLIES_COLLECTION).create({
|
|
quote: quote.id,
|
|
from_email: payload.from_email,
|
|
from_name: payload.from_name || "",
|
|
subject: payload.subject,
|
|
body_plain: payload.body_plain || "",
|
|
body_html: payload.body_html || "",
|
|
received_at: receivedAt.toISOString(),
|
|
email_message_id: payload.message_id || "",
|
|
in_reply_to: payload.in_reply_to || "",
|
|
quoted_text: payload.quoted_text || "",
|
|
});
|
|
|
|
await pb.collection(QUOTES_COLLECTION).update(quote.id, {
|
|
has_new_reply: true,
|
|
last_reply_at: receivedAt.toISOString(),
|
|
});
|
|
|
|
return { quote: quote as Quote, reply, matched_by };
|
|
}
|
|
|
|
async function resolveReplyTarget(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
payload: EmailReplyPayload
|
|
): Promise<{ quote: Quote | null; matched_by: AppendReplyResult["matched_by"] }> {
|
|
if (payload.in_reply_to) {
|
|
const q = await findQuoteByMessageId(pb, payload.in_reply_to);
|
|
if (q) return { quote: q, matched_by: "in_reply_to" };
|
|
}
|
|
const quoteNumber = extractQuoteNumberFromSubject(payload.subject);
|
|
if (quoteNumber) {
|
|
try {
|
|
const q = await pb
|
|
.collection(QUOTES_COLLECTION)
|
|
.getFirstListItem(`quote_number = "${quoteNumber}"`);
|
|
return { quote: q as Quote, matched_by: "subject" };
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return { quote: null, matched_by: "none" };
|
|
}
|
|
|
|
export function extractQuoteNumberFromSubject(subject: string): string | null {
|
|
const m = (subject || "").match(QUOTE_NUMBER_REGEX);
|
|
if (!m) return null;
|
|
// Return the full matched number string as-is (preserves whatever
|
|
// format the sender typed — dashed or dashless — so the exact-match
|
|
// lookup against quotes.quote_number resolves for both old and new
|
|
// quotes).
|
|
return m[1];
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Lookups
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Looks up a quote by the inbound email's `Message-ID` header. Returns null
|
|
* if no quote matches. Indexed by `quotes.email_message_id`.
|
|
*/
|
|
export async function findQuoteByMessageId(
|
|
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
messageId?: string
|
|
): Promise<Quote | null>;
|
|
export async function findQuoteByMessageId(messageId: string): Promise<Quote | null>;
|
|
export async function findQuoteByMessageId(
|
|
pbOrMessageId?: Awaited<ReturnType<typeof getAdminPocketBase>> | string,
|
|
maybeMessageId?: string
|
|
): Promise<Quote | null> {
|
|
let pb: Awaited<ReturnType<typeof getAdminPocketBase>>;
|
|
let messageId: string;
|
|
if (typeof pbOrMessageId === "string") {
|
|
pb = await getAdminPocketBase();
|
|
messageId = pbOrMessageId;
|
|
} else {
|
|
pb = pbOrMessageId || (await getAdminPocketBase());
|
|
messageId = maybeMessageId || "";
|
|
}
|
|
if (!messageId) return null;
|
|
const esc = messageId.replace(/"/g, '\\"');
|
|
try {
|
|
const hit = await pb
|
|
.collection(QUOTES_COLLECTION)
|
|
.getFirstListItem(`email_message_id = "${esc}"`);
|
|
return hit as Quote;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Message-ID synthesis (fallback when the inbound email has no header)
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Builds a stable, deterministic Message-ID for an email that arrived without
|
|
* one. The hash is over `from_email + subject + received_at` so the same
|
|
* email processed twice dedups to the same id. The `synth-` prefix and
|
|
* `@wfdash.local` suffix make it visually obvious in PocketBase that this
|
|
* isn't a real RFC 5322 Message-ID.
|
|
*
|
|
* Collisions are not a correctness concern: two real emails with identical
|
|
* sender + subject + received_at are the same email. The hash is FNV-1a 32-bit
|
|
* which is overkill for this input space but cheap and stable across runs.
|
|
*/
|
|
export function synthesizeMessageId(payload: {
|
|
from_email: string;
|
|
subject: string;
|
|
received_at?: string;
|
|
}): string {
|
|
const input = `${payload.from_email}|${payload.subject}|${payload.received_at ?? ""}`;
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < input.length; i++) {
|
|
hash ^= input.charCodeAt(i);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return `synth-${(hash >>> 0).toString(16).padStart(8, "0")}@wfdash.local`;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Outbound send-email pipeline
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Customer IDs / names that mean "the email intake parked the quote
|
|
* here because the inbound email was missing From: or the operator
|
|
* hasn't picked a real one yet." Sending to these is a data leak.
|
|
*
|
|
* Mirrors the same sentinel set used by the PDF route so the two
|
|
* paths agree on what counts as "real".
|
|
*/
|
|
const PLACEHOLDER_CUSTOMER_IDS = new Set<string>([
|
|
"2tmt34gzj44v9f8", // SELECT/CREATE CUSTOMER (process-page initial selection)
|
|
"dcpb38w4mk6vrm5", // Unknown Sender (email intake sentinel)
|
|
]);
|
|
const PLACEHOLDER_CUSTOMER_NAMES = new Set<string>([
|
|
"SELECT/CREATE CUSTOMER",
|
|
"Unknown Sender",
|
|
"No Header",
|
|
]);
|
|
|
|
/**
|
|
* Loose RFC 5322-ish check. We don't try to be exhaustive — we just
|
|
* want to catch obvious typos ("foo@" or "foo,bar") before n8n has to
|
|
* bounce off SMTP. Real validation happens at SMTP time.
|
|
*/
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
function looksLikeEmail(s: string): boolean {
|
|
return EMAIL_RE.test((s || "").trim());
|
|
}
|
|
|
|
/**
|
|
* Statuses where the operator is allowed to trigger a new send. We
|
|
* explicitly disallow `sending` (already in flight), `emailed`
|
|
* (already done), and the various terminal statuses (`complete`,
|
|
* `cancelled`, `rejected`).
|
|
*
|
|
* `partial_approved` was added 2026-07-24 alongside the partial-
|
|
* approval flow. The rep approves a partial quote → status flips to
|
|
* `partial_approved` → they retry the email send if the first one
|
|
* failed. The same retry semantics as `mail_failed` apply.
|
|
*/
|
|
const RESENDABLE_STATUSES = new Set<Quote["status"]>([
|
|
"approval_pending",
|
|
"partial_approval_pending",
|
|
"approved",
|
|
"partial_approved",
|
|
"mail_failed",
|
|
]);
|
|
|
|
export interface SendQuoteEmailPayload {
|
|
/** Optional override for the primary To: address. Defaults to customer.email. */
|
|
to?: string;
|
|
cc: string;
|
|
bcc: string;
|
|
subject: string;
|
|
message: string;
|
|
/** Per-modal checkbox: include the per-letterhead supplier authorization letter. */
|
|
attachAuthLetters: boolean;
|
|
/**
|
|
* IDs of `quote_attachments` rows to include in the outbound email.
|
|
* The PDF is always included separately (auto-located by kind="pdf"
|
|
* at send time). IDs that don't belong to this quote are silently
|
|
* filtered out so a tampered client can't smuggle in attachments
|
|
* from another quote.
|
|
*/
|
|
selectedAttachmentIds: string[];
|
|
}
|
|
|
|
export interface SendQuoteEmailFailure {
|
|
code:
|
|
| "placeholder_customer"
|
|
| "no_recipient"
|
|
| "invalid_recipient"
|
|
| "no_pdf"
|
|
| "invalid_status"
|
|
| "quote_not_found";
|
|
message: string;
|
|
}
|
|
|
|
export interface SendQuoteEmailResult {
|
|
quote: Quote;
|
|
/** Null on success; populated when pre-send validation rejected the send. */
|
|
failure: SendQuoteEmailFailure | null;
|
|
}
|
|
|
|
/**
|
|
* Pre-send validation + status transition for the outbound email.
|
|
*
|
|
* The actual SMTP send happens in n8n (the `Quote Outbound Send`
|
|
* workflow), triggered by a PB hook on `status = "sending"`. This
|
|
* service is the synchronous front-door:
|
|
*
|
|
* 1. Validate recipients, customer, status, and PDF.
|
|
* 2. If validation fails, flip status to `mail_failed` + write
|
|
* `email_error` synchronously and return `{ failure }`. Don't
|
|
* bother n8n — there's nothing to send.
|
|
* 3. If validation passes, flip status to `sending`, persist the
|
|
* operator's composition choices (CC/BCC/Subject/Message/
|
|
* selectedAttachmentIds/attachAuthLetters), stamp
|
|
* `sending_started_at`, and clear `email_error`. n8n picks up
|
|
* the change via PB hook and runs the SMTP send.
|
|
*
|
|
* We deliberately write `email_outbound_attachment_ids` here (not in
|
|
* n8n) so the retry flow sends the SAME attachments the operator
|
|
* chose the first time, not whatever happens to be attached at
|
|
* retry time. Without this, a check-then-uncheck between send and
|
|
* retry could cause the customer to receive a different attachment
|
|
* set on retry than on the original attempt.
|
|
*
|
|
* `email_outbound_message_id` is NOT written here — n8n generates
|
|
* it at SMTP-time and writes it on success/failure. This keeps the
|
|
* RFC 5322 Message-ID guarantee (must be unique per message) in the
|
|
* sender's hands.
|
|
*/
|
|
export async function sendQuoteEmail(
|
|
quoteId: string,
|
|
payload: SendQuoteEmailPayload
|
|
): Promise<SendQuoteEmailResult> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
// ── 1. Load quote + customer
|
|
let quote: Quote;
|
|
try {
|
|
quote = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(quoteId);
|
|
} catch {
|
|
return {
|
|
quote: null as unknown as Quote,
|
|
failure: { code: "quote_not_found", message: "Quote does not exist." },
|
|
};
|
|
}
|
|
|
|
// Load the customer separately (the getOne above doesn't expand).
|
|
const customer = quote.customer
|
|
? ((await pb.collection(CUSTOMERS_COLLECTION).getOne(quote.customer)) as Customer)
|
|
: null;
|
|
|
|
// ── 2. Validate status ────────────────────────────────────────
|
|
// Two paths to a valid "ready to send" state:
|
|
// a) Regular quote — must be in RESENDABLE_STATUSES
|
|
// (approval_pending / approved / mail_failed). The
|
|
// operator runs the full claim → process → submit for
|
|
// approval → approve → send pipeline.
|
|
// b) Delivery-note quote — skips the approval queue. The
|
|
// office claims, prices, and sends. So we accept any
|
|
// pre-send DN status here: `delivery_note` (just created
|
|
// and not yet claimed), `claimed` (operator claimed but
|
|
// hasn't priced), `processed` (priced, ready to send).
|
|
// We also still allow the regular RESENDABLE_STATUSES
|
|
// so retries from `mail_failed` keep working.
|
|
//
|
|
// The status flip downstream (step 8) goes straight to
|
|
// `sending` — we don't pass through `approval_pending` or
|
|
// `approved` for DN quotes, because the items are already
|
|
// with the customer and the operator has already verified
|
|
// them on the delivery note.
|
|
const isDeliveryNoteQuote = !!quote.delivery_note;
|
|
const dnResendableStatuses = new Set<Quote["status"]>([
|
|
"delivery_note",
|
|
"claimed",
|
|
"processed",
|
|
]);
|
|
const allowedStatuses = isDeliveryNoteQuote
|
|
? new Set<Quote["status"]>([...RESENDABLE_STATUSES, ...dnResendableStatuses])
|
|
: RESENDABLE_STATUSES;
|
|
if (!allowedStatuses.has(quote.status)) {
|
|
return markPreSendFailure(pb, quote, {
|
|
code: "invalid_status",
|
|
message: `Cannot send from status "${quote.status}". Allowed: ${[...allowedStatuses].join(", ")}.`,
|
|
});
|
|
}
|
|
|
|
// ── 3. Validate customer is real (not a sentinel). Split into
|
|
// two distinct failure modes so the UI can be precise:
|
|
// - placeholder_customer: customer is the Unknown Sender
|
|
// sentinel or one of the other placeholder rows. The
|
|
// operator must pick a real customer before sending.
|
|
// - no_recipient: customer is real but has no email
|
|
// address (primary + alternative both empty). Fix is
|
|
// to add an email to the customer record.
|
|
if (
|
|
!customer ||
|
|
PLACEHOLDER_CUSTOMER_IDS.has(customer.id) ||
|
|
PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim())
|
|
) {
|
|
return markPreSendFailure(pb, quote, {
|
|
code: "placeholder_customer",
|
|
message:
|
|
"Quote is still pointed at a placeholder / unnamed customer. Pick the real customer before sending.",
|
|
});
|
|
}
|
|
|
|
// ── 4. Validate recipient email(s). The operator may override `to`,
|
|
// but we always require *some* valid address.
|
|
const primary = (payload.to || customer.email || "").trim();
|
|
const alt = (customer.alternative_email || "").trim();
|
|
const toAddress = looksLikeEmail(primary) ? primary : looksLikeEmail(alt) ? alt : "";
|
|
if (!toAddress) {
|
|
return markPreSendFailure(pb, quote, {
|
|
code: "no_recipient",
|
|
message: "No valid recipient email on this quote (primary and alternative are both empty or invalid).",
|
|
});
|
|
}
|
|
|
|
// ── 5. Validate CC / BCC entries (loose check; SMTP will reject
|
|
// truly malformed addresses)
|
|
for (const list of [payload.cc, payload.bcc]) {
|
|
for (const raw of (list || "").split(",")) {
|
|
const v = raw.trim();
|
|
if (v && !looksLikeEmail(v)) {
|
|
return markPreSendFailure(pb, quote, {
|
|
code: "invalid_recipient",
|
|
message: `"${v}" doesn't look like an email address.`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 6. Validate PDF exists (most recent kind=pdf attachment). The
|
|
// outbound send includes the PDF as the first attachment; no
|
|
// PDF = nothing useful to send.
|
|
const pdfs = await pb
|
|
.collection(QUOTE_ATTACHMENTS_COLLECTION)
|
|
.getList(1, 1, {
|
|
filter: `quote = "${quoteId}" && kind = "pdf"`,
|
|
sort: "-uploaded_at",
|
|
});
|
|
if (pdfs.items.length === 0) {
|
|
return markPreSendFailure(pb, quote, {
|
|
code: "no_pdf",
|
|
message:
|
|
"No PDF generated for this quote yet. Generate the PDF before sending.",
|
|
});
|
|
}
|
|
|
|
// ── 7. Filter selectedAttachmentIds to only those that actually
|
|
// belong to this quote. Tampered clients can't smuggle in
|
|
// attachments from other quotes.
|
|
const requestedIds = Array.from(new Set(payload.selectedAttachmentIds || []));
|
|
let validIds: string[] = [];
|
|
if (requestedIds.length > 0) {
|
|
const quotedAtts = await pb
|
|
.collection(QUOTE_ATTACHMENTS_COLLECTION)
|
|
.getFullList({
|
|
filter: `quote = "${quoteId}"`,
|
|
fields: "id",
|
|
});
|
|
const ownIds = new Set(quotedAtts.map((a) => ({ id: (a as { id: string }).id })).map((x) => x.id));
|
|
validIds = requestedIds.filter((id) => ownIds.has(id));
|
|
}
|
|
|
|
// ── 8. Flip status to `sending` + persist composition. One PATCH
|
|
// so the audit trail stays atomic.
|
|
const updated = (await pb.collection(QUOTES_COLLECTION).update(quoteId, {
|
|
status: "sending",
|
|
sending_started_at: new Date().toISOString(),
|
|
email_error: "",
|
|
email_outbound_attachment_ids: JSON.stringify(validIds),
|
|
// Persist operator's last-used composition so reopening the
|
|
// modal shows what was sent.
|
|
email_cc: payload.cc || "",
|
|
email_bcc: payload.bcc || "",
|
|
email_subject_outbound: payload.subject || "",
|
|
email_message: payload.message || "",
|
|
attach_auth_letters: !!payload.attachAuthLetters,
|
|
})) as Quote;
|
|
|
|
return { quote: updated, failure: null };
|
|
}
|
|
|
|
/**
|
|
* Retry an outbound send after a `mail_failed`. Same validation as
|
|
* `sendQuoteEmail` but without re-reading the modal payload — we
|
|
* reuse whatever composition the operator submitted last time
|
|
* (already persisted on the quote row).
|
|
*
|
|
* Status guard: only `mail_failed` can be retried; everything else
|
|
* is either a no-op (`emailed`, `sending`) or a different transition
|
|
* (`approved` → use `sendQuoteEmail` to flip and send).
|
|
*/
|
|
export async function retryQuoteEmail(quoteId: string): Promise<SendQuoteEmailResult> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
let quote: Quote;
|
|
try {
|
|
quote = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(quoteId);
|
|
} catch {
|
|
return {
|
|
quote: null as unknown as Quote,
|
|
failure: { code: "quote_not_found", message: "Quote does not exist." },
|
|
};
|
|
}
|
|
|
|
if (quote.status !== "mail_failed") {
|
|
return markPreSendFailure(pb, quote, {
|
|
code: "invalid_status",
|
|
message: `Cannot retry from status "${quote.status}". Only mail_failed quotes can be retried.`,
|
|
});
|
|
}
|
|
|
|
const updated = (await pb.collection(QUOTES_COLLECTION).update(quoteId, {
|
|
status: "sending",
|
|
sending_started_at: new Date().toISOString(),
|
|
email_error: "",
|
|
email_failed_at: "",
|
|
})) as Quote;
|
|
|
|
return { quote: updated, failure: null };
|
|
}
|
|
|
|
/**
|
|
* Synchronously flip a quote to `mail_failed` and write the failure
|
|
* reason. Used by `sendQuoteEmail` when pre-send validation rejects
|
|
* the send — we never reach n8n in this path, so the failure has
|
|
* to be recorded by us.
|
|
*
|
|
* Returns a `SendQuoteEmailResult` shaped exactly like the success
|
|
* path so the API route doesn't have to branch.
|
|
*/
|
|
async function markPreSendFailure(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
quote: Quote,
|
|
failure: SendQuoteEmailFailure
|
|
): Promise<SendQuoteEmailResult> {
|
|
const updated = (await pb.collection(QUOTES_COLLECTION).update(quote.id, {
|
|
status: "mail_failed",
|
|
email_error: `[pre-send] ${failure.message}`,
|
|
email_failed_at: new Date().toISOString(),
|
|
})) as Quote;
|
|
return { quote: updated, failure };
|
|
}
|
|
|
|
/**
|
|
* Records the *outcome* of an SMTP send attempt. Called by the n8n
|
|
* `Quote Outbound Send` workflow (or any other worker that's been
|
|
* handed the send).
|
|
*
|
|
* `outcome = "success"` flips to `emailed` + stamps delivery fields.
|
|
* `outcome = "failure"` flips to `mail_failed` + stamps error.
|
|
*
|
|
* This is intentionally separate from `sendQuoteEmail` so the n8n
|
|
* path doesn't have to replay validation — by the time n8n runs,
|
|
* pre-send checks already passed.
|
|
*/
|
|
export interface RecordSendOutcomeInput {
|
|
quoteId: string;
|
|
outcome: "success" | "failure";
|
|
/** SMTP-issued Message-ID. Stored on success AND failure (so failures are traceable in mail logs). */
|
|
messageId?: string;
|
|
/** Failure reason (SMTP response text). Required when outcome = "failure". */
|
|
errorMessage?: string;
|
|
}
|
|
|
|
export async function recordSendOutcome(
|
|
input: RecordSendOutcomeInput
|
|
): Promise<Quote> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
if (input.outcome === "success") {
|
|
const patch: Record<string, unknown> = {
|
|
status: "emailed",
|
|
email_delivered: true,
|
|
email_delivered_at: new Date().toISOString(),
|
|
sending_started_at: "",
|
|
email_error: "",
|
|
email_failed_at: "",
|
|
};
|
|
if (input.messageId) patch.email_outbound_message_id = input.messageId;
|
|
return (await pb.collection(QUOTES_COLLECTION).update(input.quoteId, patch)) as Quote;
|
|
}
|
|
|
|
// outcome = "failure"
|
|
const reason = (input.errorMessage || "Unknown SMTP failure").slice(0, 4900);
|
|
const patch: Record<string, unknown> = {
|
|
status: "mail_failed",
|
|
email_failed_at: new Date().toISOString(),
|
|
email_error: `[smtp] ${reason}`,
|
|
sending_started_at: "",
|
|
};
|
|
if (input.messageId) patch.email_outbound_message_id = input.messageId;
|
|
return (await pb.collection(QUOTES_COLLECTION).update(input.quoteId, patch)) as Quote;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Partial-approval split — created 2026-07-24
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Result of `splitQuoteForContinuation`. The caller (the /split
|
|
* route) returns this to the UI, which surfaces a toast and
|
|
* optionally links to the new quote.
|
|
*/
|
|
export interface SplitResult {
|
|
/** The newly-created continuation quote. */
|
|
continuation: Quote;
|
|
/** How many items were moved from the parent to the continuation. */
|
|
itemsMoved: number;
|
|
/** How many items stayed on the parent (priced + approved_in_batch). */
|
|
itemsKept: number;
|
|
}
|
|
|
|
/**
|
|
* Create a continuation quote from a partial-approved parent.
|
|
*
|
|
* The parent is sealed (status: `partial_approved`, no new items can
|
|
* be added). The continuation inherits the parent's customer / rep /
|
|
* company / RFQ / notes / description, gets a fresh `quote_number`
|
|
* via `generateQuoteNumberServer()`, and starts at `status = "new"`
|
|
* with `parent_quote` pointing back at the parent.
|
|
*
|
|
* Items on the parent that are NOT yet priced (`is_processed = false`,
|
|
* `moved_to_quote = null`) are cloned to the continuation. Items that
|
|
* ARE priced (`is_processed = true`) stay on the parent — they're
|
|
* the ones the rep just approved and emailed.
|
|
*
|
|
* Items moved off the parent get PATCHed with
|
|
* `moved_to_quote = <continuation.id>` so the UI can hide them from
|
|
* the parent's "active items" list while preserving them in the DB
|
|
* for audit. They are NOT deleted — the user's hard rule is
|
|
* "PATCH over DELETE wherever possible".
|
|
*
|
|
* The continuation's items get `is_processed = false` and
|
|
* `moved_to_quote = null` (clones — not a record of having been
|
|
* moved). They show up on /sprint the next time the operator
|
|
* opens it.
|
|
*
|
|
* Edge cases:
|
|
* - Parent has no unpriced items: returns `{ continuation: parent, itemsMoved: 0 }`.
|
|
* We DO NOT create an empty quote — the split is a no-op. Caller
|
|
* should treat this as a successful "nothing to do" and skip the
|
|
* toast.
|
|
* - Parent already has a `parent_quote` set (i.e. is itself a
|
|
* continuation): we still allow the split. The chain is
|
|
* flat (Q-1 → Q-2 → Q-3) rather than nested.
|
|
*/
|
|
export async function splitQuoteForContinuation(
|
|
parentId: string
|
|
): Promise<SplitResult> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
const parent = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(parentId);
|
|
|
|
// Pull all items on the parent. We want the unpriced ones (the
|
|
// priced ones already belong to the partial-approved batch). The
|
|
// `moved_to_quote` filter excludes items already moved by a prior
|
|
// split — defends against double-clicks.
|
|
const allItems = await pb
|
|
.collection(QUOTE_ITEMS_COLLECTION)
|
|
.getFullList({
|
|
filter: `quote = "${parentId}" && (moved_to_quote = null || moved_to_quote = "")`,
|
|
});
|
|
|
|
const toMove = allItems.filter(
|
|
(it) => !it.is_processed || it.approved_in_batch !== true
|
|
);
|
|
// Priced + approved_in_batch=true items stay on the parent.
|
|
// Everything else (unpriced, or priced-but-not-yet-approved in a
|
|
// prior split) moves.
|
|
const keep = allItems.filter(
|
|
(it) => it.is_processed && it.approved_in_batch === true
|
|
);
|
|
|
|
if (toMove.length === 0) {
|
|
// Nothing to do — return the parent as the "continuation" with
|
|
// an itemsMoved=0 signal. Caller can no-op on this.
|
|
return { continuation: parent, itemsMoved: 0, itemsKept: keep.length };
|
|
}
|
|
|
|
// Create the continuation quote. Carry over the customer/company
|
|
// identity, the RFQ number (so the customer sees continuity), and
|
|
// the description / notes. Reset status, dates, and the per-quote
|
|
// workflow flags.
|
|
const newQuoteNumber = await generateQuoteNumberServer();
|
|
const now = new Date().toISOString();
|
|
const continuation = (await pb.collection(QUOTES_COLLECTION).create({
|
|
quote_number: newQuoteNumber,
|
|
status: "new",
|
|
is_template: false,
|
|
quote_date: now,
|
|
customer: parent.customer || null,
|
|
company: parent.company || null,
|
|
company_letterhead: parent.company_letterhead || "",
|
|
description: parent.description || "",
|
|
notes: parent.notes || "",
|
|
markup: parent.markup ?? null,
|
|
external_number: parent.external_number || "",
|
|
rfq_number: parent.rfq_number || "",
|
|
quote_reference: parent.quote_reference || "",
|
|
parent_quote: parent.id,
|
|
validity_days: parent.validity_days ?? null,
|
|
})) as Quote;
|
|
|
|
// Clone each "to move" item to the continuation. The clones get
|
|
// is_processed=false, approved_in_batch=false, moved_to_quote=null.
|
|
for (const it of toMove) {
|
|
const clone: Record<string, unknown> = {
|
|
quote: continuation.id,
|
|
description: it.description,
|
|
quantity: it.quantity,
|
|
quote_number: newQuoteNumber,
|
|
quote_reference: it.quote_reference,
|
|
notes: it.notes,
|
|
// Reset workflow flags
|
|
is_processed: false,
|
|
approved_in_batch: false,
|
|
moved_to_quote: null,
|
|
};
|
|
if (it.supplier) clone.supplier = it.supplier;
|
|
await pb.collection(QUOTE_ITEMS_COLLECTION).create(clone);
|
|
}
|
|
|
|
// Mark the moved items on the parent as moved. They stay in PB
|
|
// for audit but are hidden from the parent's "active" item list
|
|
// (filter: moved_to_quote = null). The pricing_notes field gets
|
|
// a breadcrumb so anyone reading the row knows where it went.
|
|
for (const it of toMove) {
|
|
await pb.collection(QUOTE_ITEMS_COLLECTION).update(it.id, {
|
|
moved_to_quote: continuation.id,
|
|
pricing_notes:
|
|
(it.pricing_notes ? it.pricing_notes + "\n" : "") +
|
|
`Moved to ${newQuoteNumber} via partial-approval split on ${now}`,
|
|
});
|
|
}
|
|
|
|
return {
|
|
continuation,
|
|
itemsMoved: toMove.length,
|
|
itemsKept: keep.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mark every priced item on a quote as `approved_in_batch = true`.
|
|
* Used by the partial-approve endpoint right before the email is
|
|
* kicked off. Idempotent: re-running on a quote where every priced
|
|
* item is already approved_in_batch=true is a no-op.
|
|
*
|
|
* Returns the count of items flipped. The caller uses this to
|
|
* surface a "N items marked for this batch" toast.
|
|
*/
|
|
export async function markPricedItemsApprovedInBatch(
|
|
quoteId: string
|
|
): Promise<number> {
|
|
const pb = await getAdminPocketBase();
|
|
const items = await pb
|
|
.collection(QUOTE_ITEMS_COLLECTION)
|
|
.getFullList({
|
|
filter: `quote = "${quoteId}" && is_processed = true && (moved_to_quote = null || moved_to_quote = "")`,
|
|
});
|
|
const toFlip = items.filter((it) => it.approved_in_batch !== true);
|
|
// Fire-and-forget the per-item updates. PB will serialise them.
|
|
// For a typical 15-item quote this is < 100ms.
|
|
await Promise.all(
|
|
toFlip.map((it) =>
|
|
pb.collection(QUOTE_ITEMS_COLLECTION).update(it.id, {
|
|
approved_in_batch: true,
|
|
})
|
|
)
|
|
);
|
|
return toFlip.length;
|
|
}
|