Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions aidmvcs-be-dev/app/api/forms/documents/[id]/pdf/route.js
Original file line number Diff line number Diff line change
@@ -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)}"` } });
}
69 changes: 51 additions & 18 deletions aidmvcs-be-dev/app/api/forms/documents/[id]/route.js
Original file line number Diff line number Diff line change
@@ -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) });
}
79 changes: 17 additions & 62 deletions aidmvcs-be-dev/app/api/forms/documents/route.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,28 @@
// 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';
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) {
Expand All @@ -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) });
}

Expand All @@ -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);
Expand All @@ -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);
}
31 changes: 0 additions & 31 deletions aidmvcs-be-dev/app/api/forms/templates/[id]/pdf/route.js

This file was deleted.

30 changes: 30 additions & 0 deletions aidmvcs-be-dev/app/api/forms/templates/[id]/preview/route.js
Original file line number Diff line number Diff line change
@@ -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' } });
}
Loading