diff --git a/aidmvcs-be-dev/app/api/forms/documents/[id]/pdf/route.js b/aidmvcs-be-dev/app/api/forms/documents/[id]/pdf/route.js
new file mode 100644
index 00000000..92fe367c
--- /dev/null
+++ b/aidmvcs-be-dev/app/api/forms/documents/[id]/pdf/route.js
@@ -0,0 +1,40 @@
+// A filled form as a PDF (the user, 13 Nov 2026: the PDF is the form's reflection, to download or print). Made from
+// the stored form and values, so a completed one always gives the same PDF; a draft is stamped DRAFT. Every download
+// / print is logged. ?dealer_id[&print=1]
+import mongoose from 'mongoose';
+import dbConnect from '@lib/mongodb';
+import FormDocument from '@models/FormDocument';
+import User from '@models/User';
+import { actorOf, canSeeFinance, fail, formsAccess } from '@lib/forms/access';
+import { visibleData } from '@lib/forms/documents';
+import { formPdf } from '@lib/forms/formPdf';
+import { getDealerTimezone } from '@lib/reporting/time';
+import moment from 'moment-timezone';
+
+export const runtime = 'nodejs';
+
+export async function GET(req, { params }) {
+ await dbConnect();
+ const q = new URL(req.url).searchParams;
+ const access = await formsAccess(req, q.get('dealer_id'));
+ if (access.response) return access.response;
+ const { id } = await params;
+ const d = mongoose.isValidObjectId(id) ? await FormDocument.findOne({ _id: id, dealer_id: access.dealerId }).lean() : null;
+ if (!d) return fail('Document not found', 404);
+ if (d.removed_at) return fail('This form was removed at the end of its retention period', 410);
+ const trades = (d.trade_in_ids || []).length;
+ const { data } = visibleData(d.schema, trades, d.data, await canSeeFinance(access.user));
+ const dealer = await User.findById(access.dealerId).select('name dealer_account_information').lean();
+ const tz = await getDealerTimezone(access.dealerId);
+ const done = d.status === 'completed';
+ const footer = `${d.template_name} v${d.template_version} · ${done ? `completed ${moment(d.completed_at).tz(tz)
+ .format('MMM D, YYYY h:mm A')} by ${d.completed_by_name || 'staff'}` : 'draft'}`;
+ const pdf = await formPdf(d.schema, data, { dealer: dealer?.dealer_account_information?.store_name || dealer?.name || '',
+ trades, stamp: done ? null : 'DRAFT', footer });
+ const actor = actorOf(access.user);
+ await FormDocument.updateOne({ _id: d._id }, { $push: { audit: { at: new Date(), by: actor.by, by_name: actor.by_name,
+ action: q.get('print') === '1' ? 'printed' : 'downloaded' } } });
+ const name = `${d.template_name || 'form'}-${new Date(d.created_at).toISOString().slice(0, 10)}.pdf`;
+ return new Response(pdf, { headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'no-store',
+ 'Content-Disposition': `${q.get('print') === '1' ? 'inline' : 'attachment'}; filename="${encodeURIComponent(name)}"` } });
+}
diff --git a/aidmvcs-be-dev/app/api/forms/documents/[id]/route.js b/aidmvcs-be-dev/app/api/forms/documents/[id]/route.js
index 61b9a47d..fbdd7c16 100644
--- a/aidmvcs-be-dev/app/api/forms/documents/[id]/route.js
+++ b/aidmvcs-be-dev/app/api/forms/documents/[id]/route.js
@@ -1,27 +1,60 @@
-// A stored form (SOW v6 Deliverable K, gap G-066): the PDF exactly as it was generated. Every download / print is
-// logged on it (who, when). ?dealer_id[&print=1]
+// One filled form (SOW v6 Deliverable K, gap G-066).
+// GET ?dealer_id -> { document: { ...row, schema, data, prefilled, hidden, trades } } (payoff / lienholder
+// hidden from staff without "Manage Leads")
+// PATCH { dealer_id, data, complete? } -> saves what was entered; complete: true locks it for good (refused while a
+// required field is empty). A completed form never changes again.
import mongoose from 'mongoose';
import dbConnect from '@lib/mongodb';
import FormDocument from '@models/FormDocument';
-import { actorOf, fail, formsAccess } from '@lib/forms/access';
-import { readFile } from '@lib/forms/storage';
+import { actorOf, canSeeFinance, fail, formsAccess, json } from '@lib/forms/access';
+import { cleanData, docRow, missingRequired, visibleData } from '@lib/forms/documents';
export const runtime = 'nodejs';
+async function load(req, params, dealerId) {
+ const access = await formsAccess(req, dealerId);
+ if (access.response) return access;
+ const { id } = await params;
+ const d = mongoose.isValidObjectId(id) ? await FormDocument.findOne({ _id: id, dealer_id: access.dealerId }) : null;
+ if (!d) return { response: fail('Document not found', 404) };
+ return { ...access, d };
+}
+
+async function view(d, user) {
+ const trades = d.trade_in_ids.length;
+ const { data, hidden } = visibleData(d.schema, trades, d.data, await canSeeFinance(user));
+ return { ...docRow(d.toObject ? d.toObject() : d), schema: d.schema, data, hidden, prefilled: d.prefilled, trades,
+ missing_required: missingRequired(d.schema, trades, d.data) };
+}
+
export async function GET(req, { params }) {
await dbConnect();
- const q = new URL(req.url).searchParams;
- const access = await formsAccess(req, q.get('dealer_id'));
- if (access.response) return access.response;
- const { id } = await params;
- const d = mongoose.isValidObjectId(id) ? await FormDocument.findOne({ _id: id, dealer_id: access.dealerId }).lean() : null;
- if (!d) return fail('Document not found', 404);
- if (!d.file_id) return fail('This copy was removed at the end of its retention period', 410);
- const pdf = await readFile(d.file_id);
- const actor = actorOf(access.user);
- await FormDocument.updateOne({ _id: d._id }, { $push: { audit: { at: new Date(), by: actor.by, by_name: actor.by_name,
- action: q.get('print') === '1' ? 'printed' : 'downloaded' } } });
- const name = `${d.template_name || 'form'}-${new Date(d.created_at).toISOString().slice(0, 10)}.pdf`;
- return new Response(pdf, { headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'no-store',
- 'Content-Disposition': `${q.get('print') === '1' ? 'inline' : 'attachment'}; filename="${encodeURIComponent(name)}"` } });
+ const found = await load(req, params, new URL(req.url).searchParams.get('dealer_id'));
+ if (found.response) return found.response;
+ return json({ document: await view(found.d, found.user) });
+}
+
+export async function PATCH(req, { params }) {
+ await dbConnect();
+ let body;
+ try { body = await req.json(); } catch { return fail('The request body must be JSON', 400); }
+ const found = await load(req, params, body?.dealer_id);
+ if (found.response) return found.response;
+ const { d, user } = found;
+ if (d.status === 'completed') return fail('This form is completed and can no longer be changed', 409);
+ if (d.removed_at) return fail('This form was removed at the end of its retention period', 410);
+ const trades = d.trade_in_ids.length;
+ const data = cleanData(d.schema, trades, body.data || {}, { finance: await canSeeFinance(user), previous: d.data });
+ const actor = actorOf(user);
+ const set = { data };
+ const audit = [{ at: new Date(), by: actor.by, by_name: actor.by_name, action: 'edited' }];
+ if (body.complete === true) {
+ const missing = missingRequired(d.schema, trades, data);
+ if (missing.length) return json({ error: `Fill the required fields first: ${missing.join(', ')}`, missing_required: missing }, 422);
+ Object.assign(set, { status: 'completed', completed_at: new Date(), completed_by_name: actor.by_name });
+ audit.push({ at: new Date(), by: actor.by, by_name: actor.by_name, action: 'completed' });
+ }
+ const res = await FormDocument.updateOne({ _id: d._id, status: 'draft' }, { $set: set, $push: { audit: { $each: audit } } });
+ if (!res.modifiedCount) return fail('Someone completed this form just now - reload it', 409);
+ return json({ document: await view(await FormDocument.findById(d._id), user) });
}
diff --git a/aidmvcs-be-dev/app/api/forms/documents/route.js b/aidmvcs-be-dev/app/api/forms/documents/route.js
index 94a6b7bd..1cf9aafb 100644
--- a/aidmvcs-be-dev/app/api/forms/documents/route.js
+++ b/aidmvcs-be-dev/app/api/forms/documents/route.js
@@ -1,12 +1,9 @@
-// Filling a form for a customer (SOW v6 Deliverable K, gaps G-065 / G-066).
-// GET ?dealer_id&customer_id -> { documents } stored for the customer (newest first)
-// POST { dealer_id, template_id, customer_id, lead_id, trade_in_ids: [...], action }
-// action 'review' -> every mapped field with the value it would get; missing ones flagged (required or not)
-// action 'preview' -> the filled PDF, not stored (blanks stay blank)
-// action 'save' -> refused while a required field is missing; else the PDF is stored as an immutable
-// snapshot against the customer and the lead -> { document }
+// A customer's forms (SOW v6 Deliverable K, gaps G-065 / G-066).
+// GET ?dealer_id&customer_id -> { documents } (newest first)
+// POST { dealer_id, template_id, customer_id, lead_id, trade_in_ids } -> a new draft: the form as it is now (its
+// current version, copied into the document) with the CRM's values already in; staff complete it on screen
// The customer, lead and trade-ins must be the dealership's and the same customer's (merged customers included); the
-// form must be active and allowed for the caller's role. Payoff / lienholder only for "Manage Leads".
+// form must be switched on and allowed for the caller's role.
import mongoose from 'mongoose';
import dbConnect from '@lib/mongodb';
import FormTemplate from '@models/FormTemplate';
@@ -14,31 +11,18 @@ import FormDocument from '@models/FormDocument';
import Lead from '@models/Lead';
import TradeIn from '@models/TradeIn';
import { actorOf, canSeeFinance, canUseTemplate, fail, formsAccess, json } from '@lib/forms/access';
-import { collectValues, isSensitive, sourceLabel } from '@lib/forms/sources';
-import { fillPdf } from '@lib/forms/render';
-import { readFile, removeFile, saveFile } from '@lib/forms/storage';
+import { collectValues } from '@lib/forms/sources';
+import { docRow, prefill } from '@lib/forms/documents';
import { customerFamily } from '@lib/customerFamily';
import { getDealerTimezone } from '@lib/reporting/time';
export const runtime = 'nodejs';
const oid = (v) => (v && mongoose.isValidObjectId(String(v)) ? new mongoose.Types.ObjectId(String(v)) : null);
-function docRow(d) {
- return { id: String(d._id), template_id: String(d.template_id), name: d.template_name, version: d.template_version,
- lead_id: d.lead_id ? String(d.lead_id) : null, trade_ins: (d.trade_in_ids || []).length, missing: d.missing || [],
- created_at: d.created_at, created_by: d.created_by_name, retain_until: d.retain_until, removed: Boolean(d.removed_at),
- downloads: (d.audit || []).filter((a) => a.action !== 'generated').length };
-}
-
-// Copies past their form's retention are removed (the file; the record of what was made stays).
+// Copies past their form's retention are cleared (what was entered); the record that a form was made stays.
async function purgeExpired(dealerId) {
- const expired = await FormDocument.find({ dealer_id: dealerId, retain_until: { $lt: new Date() }, removed_at: null })
- .select('file_id').limit(200).lean();
- for (const d of expired) {
- if (d.file_id) await removeFile(d.file_id);
- await FormDocument.updateOne({ _id: d._id }, { $set: { removed_at: new Date(), file_id: null },
- $push: { audit: { at: new Date(), action: 'removed (retention)' } } });
- }
+ await FormDocument.updateMany({ dealer_id: dealerId, retain_until: { $lt: new Date() }, removed_at: null },
+ { $set: { removed_at: new Date(), data: {} }, $push: { audit: { at: new Date(), action: 'removed (retention)' } } });
}
export async function GET(req) {
@@ -51,7 +35,7 @@ export async function GET(req) {
await purgeExpired(access.dealerId);
const family = await customerFamily(customerId, access.dealerId);
const docs = await FormDocument.find({ dealer_id: access.dealerId, customer_id: { $in: family } })
- .sort({ created_at: -1 }).limit(200).lean();
+ .select('-schema -data').sort({ created_at: -1 }).limit(200).lean();
return json({ documents: docs.map(docRow) });
}
@@ -62,21 +46,16 @@ export async function POST(req) {
const access = await formsAccess(req, body?.dealer_id);
if (access.response) return access.response;
const { user, dealerId } = access;
- const action = String(body.action || 'review');
- if (!['review', 'preview', 'save'].includes(action)) return fail('action must be review, preview or save', 400);
-
const t = oid(body.template_id) ? await FormTemplate.findOne({ _id: oid(body.template_id), dealer_id: dealerId }).lean() : null;
if (!t) return fail('Form not found', 404);
if (!t.active) return fail('This form is switched off in the Forms Library', 409);
if (!canUseTemplate(user, t)) return fail('Your role cannot use this form', 403);
const version = t.versions.find((v) => v.version === t.current_version);
- if (!version) return fail('The form has no PDF', 409);
- if (!version.mapping.length) return fail('This form has no fields mapped yet (Forms Library)', 409);
+ if (!version?.schema) return fail('This form has no fields yet (Forms Library)', 409);
const customerId = oid(body.customer_id);
if (!customerId) return fail('customer_id is required', 400);
const family = (await customerFamily(customerId, dealerId)).map(String);
- if (!family.length) return fail('Customer not found', 404);
const lead = oid(body.lead_id) ? await Lead.findOne({ _id: oid(body.lead_id), dealer_id: dealerId }).select('customer_id').lean() : null;
if (body.lead_id && (!lead || !family.includes(String(lead.customer_id)))) return fail('That lead is not this customer\'s', 400);
const tradeIds = [...new Set((Array.isArray(body.trade_in_ids) ? body.trade_in_ids : []).map(String))].slice(0, 10);
@@ -85,41 +64,17 @@ export async function POST(req) {
customer_id: { $in: family.map(oid) } });
if (own !== tradeIds.length) return fail('A trade-in picked is not this customer\'s', 400);
}
-
- const finance = await canSeeFinance(user);
let collected;
try {
collected = await collectValues({ dealerId, customerId, leadId: lead?._id || null, tradeInIds: tradeIds, user,
- canSeeFinance: finance, tz: await getDealerTimezone(dealerId) });
+ canSeeFinance: await canSeeFinance(user), tz: await getDealerTimezone(dealerId) });
} catch (e) { return fail(e.message, e.status || 500); }
- const { values, allTrades } = collected;
- const rows = version.mapping.map((m) => ({ where: m.field || `Page ${m.page}${m.label ? ` - ${m.label}` : ''}`,
- source: m.source, label: sourceLabel(m.source), value: values[m.source] || '', required: Boolean(m.required),
- missing: !values[m.source], hidden: isSensitive(m.source) && !finance }));
- const missingRequired = rows.filter((r) => r.required && r.missing).map((r) => r.label);
- if (action === 'review') {
- return json({ rows, missing_required: [...new Set(missingRequired)], trade_ins: allTrades.length,
- template: { name: t.name, version: t.current_version } });
- }
-
- const pdf = await fillPdf(await readFile(version.file_id), version.mapping, values, { allTrades });
- if (action === 'preview') {
- return new Response(pdf, { headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'no-store',
- 'Content-Disposition': `inline; filename="preview.pdf"` } });
- }
- if (missingRequired.length) {
- return json({ error: `Required fields are empty: ${[...new Set(missingRequired)].join(', ')}. Fill them on the `
- + 'customer / lead / trade-in first.', missing_required: [...new Set(missingRequired)] }, 422);
- }
- const fileId = await saveFile(pdf, { filename: `${t.name}.pdf`, dealerId, kind: 'document' });
+ const { data, prefilled } = prefill(version.schema, collected);
const actor = actorOf(user);
- const snapshot = Object.fromEntries(version.mapping.map((m) => [m.source, isSensitive(m.source) ? (values[m.source]
- ? '(entered)' : '') : values[m.source] || '']));
const doc = await FormDocument.create({ dealer_id: dealerId, template_id: t._id, template_name: t.name,
- template_version: t.current_version, customer_id: customerId, lead_id: lead?._id || null, trade_in_ids: tradeIds.map(oid),
- values: snapshot, missing: [...new Set(rows.filter((r) => r.missing).map((r) => r.label))], file_id: fileId,
- size: pdf.length, created_by: actor.by, created_by_name: actor.by_name,
+ template_version: version.version, schema: version.schema, data, prefilled, customer_id: customerId,
+ lead_id: lead?._id || null, trade_in_ids: tradeIds.map(oid), created_by: actor.by, created_by_name: actor.by_name,
retain_until: t.retention_days ? new Date(Date.now() + t.retention_days * 864e5) : null,
- audit: [{ at: new Date(), by: actor.by, by_name: actor.by_name, action: 'generated' }] });
+ audit: [{ at: new Date(), by: actor.by, by_name: actor.by_name, action: 'started' }] });
return json({ document: docRow(doc.toObject()) }, 201);
}
diff --git a/aidmvcs-be-dev/app/api/forms/templates/[id]/pdf/route.js b/aidmvcs-be-dev/app/api/forms/templates/[id]/pdf/route.js
deleted file mode 100644
index d001065a..00000000
--- a/aidmvcs-be-dev/app/api/forms/templates/[id]/pdf/route.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// A form's PDF for checking it (SOW v6 Deliverable K, gap G-065 "visual/config mapping ... preview").
-// GET ?dealer_id[&version=N][&layout=1][&grid=1]
-// layout=1: each mapped field / placed box shows what fills it ("[Customer: Full name]"); grid=1 adds a 50-point
-// grid with its coordinates, to place boxes on a flat PDF. No customer data is ever put in.
-import mongoose from 'mongoose';
-import dbConnect from '@lib/mongodb';
-import FormTemplate from '@models/FormTemplate';
-import { fail, formsAccess } from '@lib/forms/access';
-import { sourceLabel } from '@lib/forms/sources';
-import { layoutPdf } from '@lib/forms/render';
-import { readFile } from '@lib/forms/storage';
-
-export const runtime = 'nodejs';
-
-export async function GET(req, { params }) {
- await dbConnect();
- const q = new URL(req.url).searchParams;
- const access = await formsAccess(req, q.get('dealer_id'));
- if (access.response) return access.response;
- const { id } = await params;
- const t = mongoose.isValidObjectId(id) ? await FormTemplate.findOne({ _id: id, dealer_id: access.dealerId }).lean() : null;
- if (!t) return fail('Form not found', 404);
- const v = t.versions.find((x) => x.version === Number(q.get('version') || t.current_version));
- if (!v) return fail('No such version', 404);
- let pdf = await readFile(v.file_id);
- if (q.get('layout') === '1' || q.get('grid') === '1') {
- pdf = await layoutPdf(pdf, q.get('layout') === '1' ? v.mapping : [], sourceLabel, { grid: q.get('grid') === '1' });
- }
- return new Response(pdf, { headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'no-store',
- 'Content-Disposition': `inline; filename="${encodeURIComponent(t.name)}-v${v.version}.pdf"` } });
-}
diff --git a/aidmvcs-be-dev/app/api/forms/templates/[id]/preview/route.js b/aidmvcs-be-dev/app/api/forms/templates/[id]/preview/route.js
new file mode 100644
index 00000000..4ddeb4b4
--- /dev/null
+++ b/aidmvcs-be-dev/app/api/forms/templates/[id]/preview/route.js
@@ -0,0 +1,30 @@
+// "This is how it will look": the form being edited (unsaved changes included) as a PDF, blank - no customer data.
+// POST { dealer_id, schema }
+import mongoose from 'mongoose';
+import dbConnect from '@lib/mongodb';
+import FormTemplate from '@models/FormTemplate';
+import User from '@models/User';
+import { fail, formsAccess } from '@lib/forms/access';
+import { normalizeSchema } from '@lib/forms/schema';
+import { isSource } from '@lib/forms/sources';
+import { formPdf } from '@lib/forms/formPdf';
+
+export const runtime = 'nodejs';
+
+export async function POST(req, { params }) {
+ await dbConnect();
+ let body;
+ try { body = await req.json(); } catch { return fail('The request body must be JSON', 400); }
+ const access = await formsAccess(req, body?.dealer_id);
+ if (access.response) return access.response;
+ const { id } = await params;
+ if (!mongoose.isValidObjectId(id) || !(await FormTemplate.exists({ _id: id, dealer_id: access.dealerId }))) {
+ return fail('Form not found', 404);
+ }
+ let schema;
+ try { schema = normalizeSchema(body.schema, { isSource }); } catch (e) { return fail(e.message, 400); }
+ const dealer = await User.findById(access.dealerId).select('name dealer_account_information').lean();
+ const pdf = await formPdf(schema, {}, { dealer: dealer?.dealer_account_information?.store_name || dealer?.name || '',
+ trades: 1, stamp: 'PREVIEW', footer: 'Preview of the form - blank' });
+ return new Response(pdf, { headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'no-store' } });
+}
diff --git a/aidmvcs-be-dev/app/api/forms/templates/[id]/route.js b/aidmvcs-be-dev/app/api/forms/templates/[id]/route.js
index 7779435f..e2d2cf9c 100644
--- a/aidmvcs-be-dev/app/api/forms/templates/[id]/route.js
+++ b/aidmvcs-be-dev/app/api/forms/templates/[id]/route.js
@@ -1,16 +1,17 @@
// One form of the library (SOW v6 Deliverable K, gaps G-064 / G-065). Library managers only, except GET.
-// GET ?dealer_id -> { template (current version: fields, pages, mapping), versions, history,
-// sources, roles, statuses, can_manage }
-// PATCH { dealer_id, name?, category?, active?, departments?, role_ids?, statuses?, retention_days?, mapping? }
-// mapping = the current version's [{ field | page,x,y,size,label, source, required }], checked against the PDF
-// POST multipart: dealer_id, file -> replaces the PDF with a new version; the mapping is carried over for every
-// field / page the new PDF still has (the rest is listed in `dropped`). Older versions are kept.
+// GET ?dealer_id -> { template (with the current version's schema), sources, roles, statuses, can_manage }
+// PATCH { dealer_id, name?, category?, active?, departments?, role_ids?, statuses?, retention_days?, schema? }
+// schema = the edited form (lib/forms/schema.js). Saved over the current version while no customer form uses
+// it; once one does, it is saved as a new version (that customer's copy keeps the version it was made with).
+// POST multipart: dealer_id, file -> the new PDF is read into a new version of the form (the dealer then edits it)
import mongoose from 'mongoose';
import dbConnect from '@lib/mongodb';
import FormTemplate, { FORM_CATEGORIES } from '@models/FormTemplate';
+import FormDocument from '@models/FormDocument';
import { actorOf, canManageForms, dealerRoles, fail, formsAccess, json, templateSummary } from '@lib/forms/access';
import { SOURCES, isSource } from '@lib/forms/sources';
-import { inspectPdf } from '@lib/forms/render';
+import { normalizeSchema } from '@lib/forms/schema';
+import { extractForm } from '@lib/forms/extract';
import { saveFile } from '@lib/forms/storage';
export const runtime = 'nodejs';
@@ -30,10 +31,9 @@ const current = (t) => t.versions.find((v) => v.version === t.current_version);
function detail(t) {
const v = current(t);
- return { ...templateSummary(t.toObject()), fields: v?.fields || [], pages: v?.pages || [], mapping: v?.mapping || [],
- filename: v?.filename || null,
- version_list: t.versions.map((x) => ({ version: x.version, filename: x.filename, fillable: x.fillable,
- uploaded_at: x.uploaded_at, uploaded_by: x.uploaded_by_name, mapped: x.mapping.length })).reverse(),
+ return { ...templateSummary(t.toObject()), schema: v?.schema || null, source_filename: v?.source_filename || null,
+ version_list: t.versions.map((x) => ({ version: x.version, created_at: x.created_at, created_by: x.created_by_name,
+ from_pdf: x.source_filename })).reverse(),
history: t.history.slice(-30).reverse() };
}
@@ -42,35 +42,7 @@ export async function GET(req, { params }) {
const found = await load(req, params, new URL(req.url).searchParams.get('dealer_id'));
if (found.response) return found.response;
return json({ template: detail(found.t), sources: SOURCES, roles: await dealerRoles(found.dealerId),
- statuses: LEAD_STATUSES, can_manage: await canManageForms(found.user) });
-}
-
-// The mapping as sent, checked against the version's PDF: a fillable PDF maps its own field names; a flat one places
-// boxes on its pages. Returns { mapping } or { error }.
-function checkMapping(input, version) {
- if (!Array.isArray(input)) return { error: 'mapping must be a list' };
- if (input.length > 300) return { error: 'At most 300 mapped fields' };
- const names = new Set(version.fields.map((f) => f.name));
- const out = [];
- for (const [i, m] of input.entries()) {
- const n = `Row ${i + 1}`;
- if (!isSource(m?.source)) return { error: `${n}: pick what fills it` };
- if (m.field) {
- if (!names.has(String(m.field))) return { error: `${n}: the PDF has no field "${m.field}"` };
- out.push({ field: String(m.field), source: m.source, required: Boolean(m.required) });
- continue;
- }
- const page = Number(m.page); const x = Number(m.x); const y = Number(m.y); const size = Number(m.size || 10);
- const p = version.pages[page - 1];
- if (!p) return { error: `${n}: page ${m.page} is not in the PDF (it has ${version.pages.length})` };
- if (![x, y].every(Number.isFinite) || x < 0 || y < 0 || x > p.width || y > p.height) {
- return { error: `${n}: the position must be on the page (x 0-${p.width}, y 0-${p.height})` };
- }
- if (!(size >= 5 && size <= 24)) return { error: `${n}: text size 5-24` };
- out.push({ page, x, y, size, label: String(m.label || '').slice(0, 60) || null, source: m.source,
- required: Boolean(m.required) });
- }
- return { mapping: out };
+ statuses: LEAD_STATUSES, categories: FORM_CATEGORIES, can_manage: await canManageForms(found.user) });
}
export async function PATCH(req, { params }) {
@@ -86,15 +58,15 @@ export async function PATCH(req, { params }) {
if (body.name !== undefined) {
const name = String(body.name || '').trim().slice(0, 120);
if (!name) return fail('Give the form a name', 400);
- if (name !== t.name) { changes.push(`name "${t.name}" -> "${name}"`); t.name = name; }
+ if (name !== t.name) { changes.push(`name "${name}"`); t.name = name; }
}
if (body.category !== undefined) {
if (!FORM_CATEGORIES.includes(body.category)) return fail('Unknown category', 400);
if (body.category !== t.category) { changes.push(`category ${body.category}`); t.category = body.category; }
}
if (body.departments !== undefined) {
- const d = [...new Set((body.departments || []).filter((x) => ['sales', 'service'].includes(x)))];
- t.departments = d; changes.push(`departments: ${d.join(', ') || 'both'}`);
+ t.departments = [...new Set((body.departments || []).filter((x) => ['sales', 'service'].includes(x)))];
+ changes.push(`departments: ${t.departments.join(', ') || 'both'}`);
}
if (body.role_ids !== undefined) {
const own = new Set((await dealerRoles(found.dealerId)).map((r) => r.id));
@@ -103,26 +75,38 @@ export async function PATCH(req, { params }) {
t.role_ids = ids; changes.push(`roles: ${ids.length || 'all'}`);
}
if (body.statuses !== undefined) {
- const s = (body.statuses || []).filter((x) => LEAD_STATUSES.includes(x));
- t.statuses = s; changes.push(`available at: ${s.join(', ') || 'any status'}`);
+ t.statuses = (body.statuses || []).filter((x) => LEAD_STATUSES.includes(x));
+ changes.push(`available at: ${t.statuses.join(', ') || 'any status'}`);
}
if (body.retention_days !== undefined) {
const r = body.retention_days === null || body.retention_days === '' ? null : Number(body.retention_days);
if (r !== null && !(Number.isInteger(r) && r >= 30 && r <= 3650)) return fail('Keep copies 30 to 3650 days, or leave it empty to keep them', 400);
t.retention_days = r; changes.push(`copies kept: ${r ? `${r} days` : 'always'}`);
}
+ if (body.schema !== undefined) {
+ let schema;
+ try { schema = normalizeSchema(body.schema, { isSource }); } catch (e) { return fail(e.message, 400); }
+ const v = current(t);
+ const used = v && await FormDocument.exists({ template_id: t._id, template_version: v.version });
+ if (!v || used) {
+ const version = Math.max(0, ...t.versions.map((x) => x.version)) + 1;
+ t.versions.push({ version, schema, source_file_id: v?.source_file_id || null, source_filename: v?.source_filename || null,
+ created_by: actor.by, created_by_name: actor.by_name });
+ t.current_version = version;
+ t.history.push({ ...actor, action: 'new_version', detail: `version ${version} (the form was edited after customers' forms were made)` });
+ } else {
+ v.schema = schema;
+ t.markModified('versions');
+ t.history.push({ ...actor, action: 'edited', detail: `version ${v.version}` });
+ }
+ }
if (body.active !== undefined && Boolean(body.active) !== t.active) {
+ if (body.active && !(current(t)?.schema?.sections || []).some((s) => s.fields.length)) {
+ return fail('Add the form\'s fields before switching it on', 400);
+ }
t.active = Boolean(body.active);
t.history.push({ ...actor, action: t.active ? 'activated' : 'deactivated' });
}
- if (body.mapping !== undefined) {
- const v = current(t);
- const checked = checkMapping(body.mapping, v);
- if (checked.error) return fail(checked.error, 400);
- v.mapping = checked.mapping;
- t.markModified('versions');
- t.history.push({ ...actor, action: 'mapping', detail: `version ${v.version}: ${checked.mapping.length} fields mapped` });
- }
if (changes.length) t.history.push({ ...actor, action: 'settings', detail: changes.join('; ') });
await t.save();
return json({ template: detail(t) });
@@ -138,24 +122,17 @@ export async function POST(req, { params }) {
const file = form.get('file');
if (!file || typeof file.arrayBuffer !== 'function') return fail('Choose the new PDF', 400);
const buffer = Buffer.from(await file.arrayBuffer());
- let info;
- try { info = await inspectPdf(buffer); } catch (e) { return fail(e.message, e.status || 400); }
+ let read;
+ try { read = await extractForm(buffer, { fallbackTitle: found.t.name }); } catch (e) { return fail(e.message, e.status || 400); }
const { t } = found;
- const prev = current(t);
- const names = new Set(info.fields.map((f) => f.name));
- const kept = []; const dropped = [];
- for (const m of prev?.mapping || []) {
- const ok = m.field ? names.has(m.field) : Boolean(info.pages[m.page - 1]);
- (ok ? kept : dropped).push(m.toObject ? m.toObject() : m);
- }
- const fileId = await saveFile(buffer, { filename: file.name, dealerId: found.dealerId, kind: 'template' });
const actor = actorOf(found.user);
- const version = Math.max(...t.versions.map((v) => v.version)) + 1;
- t.versions.push({ version, file_id: fileId, filename: file.name || null, size: buffer.length, ...info, mapping: kept,
- uploaded_by: actor.by, uploaded_by_name: actor.by_name });
+ const sourceId = await saveFile(buffer, { filename: file.name, dealerId: found.dealerId, kind: 'template_source' });
+ const version = Math.max(0, ...t.versions.map((x) => x.version)) + 1;
+ t.versions.push({ version, schema: read.schema, source_file_id: sourceId, source_filename: file.name || null,
+ created_by: actor.by, created_by_name: actor.by_name });
t.current_version = version;
- t.history.push({ ...actor, action: 'new_version', detail: `version ${version}: ${file.name || 'PDF'}; ${kept.length} `
- + `mapped fields kept${dropped.length ? `, ${dropped.length} no longer on the PDF` : ''}` });
+ t.history.push({ ...actor, action: 'new_version', detail: `version ${version} read from ${file.name || 'a PDF'}: `
+ + `${read.found.sections} sections, ${read.found.fields} fields` });
await t.save();
- return json({ template: detail(t), dropped: dropped.map((m) => m.field || `page ${m.page} (${m.label || m.source})`) });
+ return json({ template: detail(t), found: read.found });
}
diff --git a/aidmvcs-be-dev/app/api/forms/templates/[id]/source/route.js b/aidmvcs-be-dev/app/api/forms/templates/[id]/source/route.js
new file mode 100644
index 00000000..13979853
--- /dev/null
+++ b/aidmvcs-be-dev/app/api/forms/templates/[id]/source/route.js
@@ -0,0 +1,21 @@
+// The PDF a form version was read from, for reference while editing it. ?dealer_id[&version=N]
+import mongoose from 'mongoose';
+import dbConnect from '@lib/mongodb';
+import FormTemplate from '@models/FormTemplate';
+import { fail, formsAccess } from '@lib/forms/access';
+import { readFile } from '@lib/forms/storage';
+
+export const runtime = 'nodejs';
+
+export async function GET(req, { params }) {
+ await dbConnect();
+ const q = new URL(req.url).searchParams;
+ const access = await formsAccess(req, q.get('dealer_id'));
+ if (access.response) return access.response;
+ const { id } = await params;
+ const t = mongoose.isValidObjectId(id) ? await FormTemplate.findOne({ _id: id, dealer_id: access.dealerId }).lean() : null;
+ const v = t?.versions.find((x) => x.version === Number(q.get('version') || t.current_version));
+ if (!v?.source_file_id) return fail('No uploaded PDF for this form', 404);
+ return new Response(await readFile(v.source_file_id), { headers: { 'Content-Type': 'application/pdf',
+ 'Cache-Control': 'no-store', 'Content-Disposition': `inline; filename="${encodeURIComponent(v.source_filename || 'form.pdf')}"` } });
+}
diff --git a/aidmvcs-be-dev/app/api/forms/templates/route.js b/aidmvcs-be-dev/app/api/forms/templates/route.js
index a72e0b1f..fc6b08e5 100644
--- a/aidmvcs-be-dev/app/api/forms/templates/route.js
+++ b/aidmvcs-be-dev/app/api/forms/templates/route.js
@@ -1,17 +1,19 @@
-// The dealership's Forms & Documents Library (SOW v6 Deliverable K, gap G-064).
-// GET ?dealer_id -> { templates, can_manage, categories } (everything, for the library)
-// GET ?dealer_id&usable=1&lead_id=... -> { templates } the caller may fill for that lead: active, their role, the
-// lead's department (service if it has a service booking) and status
-// POST multipart: dealer_id, name, category, file (PDF) -> the new form, version 1 (managers of the library only)
-// Errors: 400 bad input / not a PDF / locked PDF · 401 · 403 not this dealership's, or not allowed to manage forms.
+// The dealership's Forms Library (SOW v6 Deliverable K, gap G-064).
+// GET ?dealer_id -> { templates, can_manage, categories }
+// GET ?dealer_id&usable=1&lead_id=... -> { templates } the caller may fill for that lead: switched on, their role,
+// the lead's department (service if it has a service booking) and status
+// POST multipart: dealer_id, name, category, file (PDF) -> the PDF is read into an on-screen form (lib/forms/
+// extract.js), saved switched off for the dealer to check and edit in the builder
+// POST JSON { dealer_id, name, category, blank: true } -> an empty form to build from scratch
+// Errors: 400 bad input / not a PDF / locked · 422 a scanned PDF (no text) · 401 · 403 (not allowed to manage forms).
import dbConnect from '@lib/mongodb';
import FormTemplate, { FORM_CATEGORIES } from '@models/FormTemplate';
import Lead from '@models/Lead';
import Booking from '@models/Booking';
-import { actorOf, canManageForms, canUseTemplate, fail, formsAccess, json } from '@lib/forms/access';
-import { inspectPdf } from '@lib/forms/render';
+import { actorOf, canManageForms, canUseTemplate, fail, formsAccess, json, templateSummary } from '@lib/forms/access';
+import { extractForm } from '@lib/forms/extract';
+import { newId } from '@lib/forms/schema';
import { saveFile } from '@lib/forms/storage';
-import { templateSummary as summary } from '@lib/forms/access';
export const runtime = 'nodejs';
@@ -22,39 +24,51 @@ export async function GET(req) {
if (access.response) return access.response;
const templates = await FormTemplate.find({ dealer_id: access.dealerId }).select('-history').sort({ name: 1 }).lean();
if (params.get('usable') !== '1') {
- return json({ templates: templates.map(summary), can_manage: await canManageForms(access.user),
+ return json({ templates: templates.map(templateSummary), can_manage: await canManageForms(access.user),
categories: FORM_CATEGORIES });
}
const lead = params.get('lead_id') ? await Lead.findOne({ _id: params.get('lead_id'), dealer_id: access.dealerId })
.select('fe_lead_status').lean() : null;
const service = lead ? Boolean(await Booking.exists({ lead_id: String(lead._id), appointment_type: 'service' })) : false;
const usable = templates.filter((t) => t.active && canUseTemplate(access.user, t)
+ && t.versions.some((v) => v.version === t.current_version && v.schema)
&& (!t.departments?.length || t.departments.includes(service ? 'service' : 'sales'))
&& (!t.statuses?.length || (lead && t.statuses.includes(lead.fe_lead_status))));
- return json({ templates: usable.map(summary) });
+ return json({ templates: usable.map(templateSummary) });
}
export async function POST(req) {
await dbConnect();
- let form;
- try { form = await req.formData(); } catch { return fail('Send the form as multipart/form-data', 400); }
- const access = await formsAccess(req, form.get('dealer_id'));
+ const isJson = (req.headers.get('content-type') || '').includes('application/json');
+ let input;
+ try { input = isJson ? await req.json() : Object.fromEntries((await req.formData()).entries()); }
+ catch { return fail('Send a PDF (multipart) or { blank: true }', 400); }
+ const access = await formsAccess(req, input.dealer_id);
if (access.response) return access.response;
if (!(await canManageForms(access.user))) return fail('You do not have permission to manage forms', 403);
- const name = String(form.get('name') || '').trim().slice(0, 120);
- const category = String(form.get('category') || 'Other');
- const file = form.get('file');
- if (!name) return fail('Give the form a name', 400);
+ const category = String(input.category || 'Other');
if (!FORM_CATEGORIES.includes(category)) return fail(`category must be one of: ${FORM_CATEGORIES.join(', ')}`, 400);
- if (!file || typeof file.arrayBuffer !== 'function') return fail('Choose the PDF to upload', 400);
- const buffer = Buffer.from(await file.arrayBuffer());
- let info;
- try { info = await inspectPdf(buffer); } catch (e) { return fail(e.message, e.status || 400); }
- const fileId = await saveFile(buffer, { filename: file.name, dealerId: access.dealerId, kind: 'template' });
+ const file = input.file;
const actor = actorOf(access.user);
- const t = await FormTemplate.create({ dealer_id: access.dealerId, name, category, current_version: 1,
- versions: [{ version: 1, file_id: fileId, filename: file.name || null, size: buffer.length, ...info,
- uploaded_by: actor.by, uploaded_by_name: actor.by_name }],
- history: [{ ...actor, action: 'uploaded', detail: `${file.name || 'PDF'} (${info.fillable ? `fillable, ${info.fields.length} fields` : 'not fillable'})` }] });
- return json({ template: summary(t.toObject()) }, 201);
+ let schema; let sourceId = null; let detail; let found = null;
+ if (file && typeof file.arrayBuffer === 'function') {
+ const buffer = Buffer.from(await file.arrayBuffer());
+ let read;
+ try { read = await extractForm(buffer, { fallbackTitle: String(input.name || '').trim() || 'Form' }); }
+ catch (e) { return fail(e.message, e.status || 400); }
+ schema = read.schema;
+ found = read.found;
+ sourceId = await saveFile(buffer, { filename: file.name, dealerId: access.dealerId, kind: 'template_source' });
+ detail = `read from ${file.name || 'a PDF'}: ${read.found.sections} sections, ${read.found.fields} fields`;
+ } else if (String(input.blank) === 'true') {
+ schema = { title: String(input.name || 'New form').trim(), description: null,
+ sections: [{ id: newId('s'), title: '', repeat: null, fields: [] }] };
+ detail = 'started blank';
+ } else return fail('Choose the PDF to upload, or start a blank form', 400);
+ const name = String(input.name || '').trim().slice(0, 120) || schema.title.slice(0, 120);
+ const t = await FormTemplate.create({ dealer_id: access.dealerId, name, category, active: false, current_version: 1,
+ versions: [{ version: 1, schema, source_file_id: sourceId, source_filename: file?.name || null,
+ created_by: actor.by, created_by_name: actor.by_name }],
+ history: [{ ...actor, action: 'created', detail }] });
+ return json({ template: templateSummary(t.toObject()), found }, 201);
}
diff --git a/aidmvcs-be-dev/app/dealer/customers/[id]/components/DocumentsTab.js b/aidmvcs-be-dev/app/dealer/customers/[id]/components/DocumentsTab.js
index 3e0aeae7..a9834ca5 100644
--- a/aidmvcs-be-dev/app/dealer/customers/[id]/components/DocumentsTab.js
+++ b/aidmvcs-be-dev/app/dealer/customers/[id]/components/DocumentsTab.js
@@ -1,18 +1,74 @@
"use client";
-// A customer's dealership forms (SOW v6 Deliverable K, gaps G-065 / G-066): pick the lead (the opportunity), the form
-// and the trade-ins -> check every value that goes in (missing ones flagged, required ones block saving) -> preview
-// the PDF -> save the copy, which is stored on the customer and the lead exactly as generated -> download / print.
-// The forms are the dealership's own (Admin > Forms Library).
+// A customer's dealership forms (SOW v6 Deliverable K, gaps G-065 / G-066). Start a form: pick the lead (the
+// opportunity), the form and the trade-ins -> the on-screen form opens with what the CRM knows already in -> staff
+// complete the rest -> Save draft (come back later) or Complete (locked for good; required fields first) -> the PDF
+// downloads or prints. The forms are the dealership's own (Admin > Forms Library).
import { useCallback, useEffect, useState } from "react";
-import { Alert, Badge, Button, Col, Form, Row, Spinner, Table } from "react-bootstrap";
+import { Alert, Badge, Button, Col, Form, Modal, Row, Spinner, Table } from "react-bootstrap";
import { useUser } from "../../../context/UserContext";
import { formsGet, formsSend, openPdf } from "../../../forms/formsApi";
+import FormView from "../../../forms/components/FormView";
const auth = () => ({ Authorization: `Bearer ${localStorage.getItem("dealertoken")}` });
const leadLabel = (l) => [[l.vehicle_year, l.vehicle_make, l.vehicle_model].filter(Boolean).join(" ") || "No vehicle",
l.fe_lead_status, l.createdAt ? new Date(l.createdAt).toLocaleDateString() : null].filter(Boolean).join(" · ");
const tradeLabel = (t) => [[t.year, t.make, t.model].filter(Boolean).join(" ") || "Trade-in", t.vin].filter(Boolean).join(" · ");
+function FillModal({ dealerId, id, onHide, onChanged }) {
+ const [doc, setDoc] = useState(null);
+ const [data, setData] = useState({});
+ const [dirty, setDirty] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ useEffect(() => {
+ formsGet(`/api/forms/documents/${id}?dealer_id=${dealerId}`).then((d) => { setDoc(d.document); setData(d.document.data); })
+ .catch((e) => setError(e.message));
+ }, [id, dealerId]);
+ const save = async (complete) => {
+ setBusy(true); setError(null);
+ try {
+ const d = await formsSend(`/api/forms/documents/${id}`, "PATCH", { dealer_id: dealerId, data, complete });
+ setDoc(d.document); setData(d.document.data); setDirty(false); onChanged();
+ return true;
+ } catch (e) { setError(e.message); return false; } finally { setBusy(false); }
+ };
+ const pdf = async (print) => {
+ if (dirty && !(await save(false))) return;
+ setBusy(true);
+ try { await openPdf(`/api/forms/documents/${id}/pdf?dealer_id=${dealerId}${print ? "&print=1" : ""}`, print ? {} : { download: `${doc.name}.pdf` }); onChanged(); }
+ catch (e) { setError(e.message); } finally { setBusy(false); }
+ };
+ const close = () => { if (!dirty || window.confirm("Close without saving your changes?")) onHide(); };
+ const done = doc?.status === "completed";
+ return (
+
| Where on the form | Filled with | Value |
|---|---|---|
| {r.where} | {r.label}{r.required && |
- {r.hidden ? managers only - : r.missing ? missing - left blank : r.value} | -
None yet.
: ( -| Form | Lead | Made by | Missing when made |
|---|
| Form | Lead | Started | Status | |
|---|---|---|---|---|
| {d.name} v{d.version} | -{d.lead_id && leads.find((l) => l._id === d.lead_id) ? leadLabel(leads.find((l) => l._id === d.lead_id)) : "-"} | -{d.created_by || "Staff"}, {new Date(d.created_at).toLocaleString()} | -{d.missing.length ? d.missing.join(", ") : "-"} | -- {d.removed ? removed (retention) : ( - <> - - - > - )} - | -
| {d.name} v{d.version} | +{lead ? leadLabel(lead) : "-"} | +{d.created_by || "Staff"}, {new Date(d.created_at).toLocaleString()} | +{d.removed ? removed (retention) : d.status === "completed"
+ ? |
+
Click a field on the form to change it.
: ( + <> +{schema.description}
} + {schema.sections.map((s) => { + const copies = s.repeat === "trade_in" ? Math.max(1, design ? 1 : trades) : 1; + return Array.from({ length: copies }, (_, n) => ( +- {t.fillable ? `The PDF's ${t.fields.length} fields. Pick what fills each one; a field left blank stays blank.` - : `This PDF has no fields: place a box for each value - page, then the position in points from the bottom-left ` - + `corner (open "Show grid" to read them off the page) and the text size.`} - {" "}Required fields must have a value before a copy can be saved. Payoff and lienholder print only for managers. -
-| PDF field | Filled with | Required | |||||
|---|---|---|---|---|---|---|---|
| Page | x | y | Size | Label | Filled with | Required | |
| {m.field} {m.type?.replace("Field", "")} | -|||||||
| - |
| v{v.version} {v.version === t.current_version && |
- {v.filename} | {v.fillable ? "fillable" : "flat"} | {v.mapped} mapped | -{v.uploaded_by || "Staff"}, {new Date(v.uploaded_at).toLocaleString()} | -- |
- Your dealership's own PDF forms. AutoPulse fills them in from the customer, the lead, the vehicle and - the trade-ins - it never makes up a value - and keeps every filled copy on the customer. The forms' - legal wording is yours. Fill a form from a customer's Documents tab. + Your dealership's forms. Upload a form's PDF and it is turned into an on-screen form you can edit - + every label, field and section. Staff fill it from a customer's Documents tab: what the + CRM knows is already in, they complete the rest, and it downloads or prints as a PDF. The legal wording is + yours.
{data && !data.templates.length ? ( -| Form | Category | Type | Version | Fields mapped | +||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Form | Category | Fields | Version | Who / when | Status | |||||
| {t.name} | {t.category} | -{t.fillable ? "Fillable PDF" : "Flat PDF"} | -v{t.current_version}{t.versions > 1 ? ` (${t.versions} uploads)` : ""} | -{t.mapped ? `${t.mapped}${t.required ? ` (${t.required} required)` : ""}` : Not mapped yet} | +{t.ready ? t.fields : Old upload - open and read the PDF again} | +v{t.current_version} |
{t.departments.length ? t.departments.join(", ") : "Sales & service"} ·{" "}
{t.role_ids.length ? `${t.role_ids.length} role(s)` : "all roles"} ·{" "}
@@ -87,7 +87,7 @@ export default function FormsLibraryPage() {
|