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 ( + + + {doc?.name || "Form"} {doc && (done ? Completed : Draft)} + + + {error && {error}} + {!doc ? : ( + <> + {done ? Completed by {doc.completed_by || "staff"} on{" "} + {new Date(doc.completed_at).toLocaleString()}. It can no longer be changed. + : doc.missing_required.length > 0 && Still required:{" "} + {doc.missing_required.join(", ")}} + + + + + {!done && } + {!done && } + + + ); +} + export default function DocumentsTab({ customerId }) { const { dealerParent } = useUser(); const dealerId = dealerParent?.id; @@ -21,10 +77,9 @@ export default function DocumentsTab({ customerId }) { const [docs, setDocs] = useState(null); const [templates, setTemplates] = useState([]); const [pick, setPick] = useState({ lead_id: "", template_id: "", trade_in_ids: [] }); - const [review, setReview] = useState(null); + const [open, setOpen] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); const loadDocs = useCallback(async () => { if (!dealerId) return; @@ -47,7 +102,6 @@ export default function DocumentsTab({ customerId }) { useEffect(() => { if (!dealerId) return; - setReview(null); formsGet(`/api/forms/templates?dealer_id=${dealerId}&usable=1${pick.lead_id ? `&lead_id=${pick.lead_id}` : ""}`) .then((d) => { setTemplates(d.templates); @@ -55,106 +109,61 @@ export default function DocumentsTab({ customerId }) { }).catch((e) => setError(e.message)); }, [dealerId, pick.lead_id]); - const body = (action) => ({ dealer_id: dealerId, customer_id: customerId, ...pick, lead_id: pick.lead_id || null, action }); - const run = async (fn) => { - setBusy(true); setError(null); setNotice(null); - try { await fn(); } catch (e) { setError(e.message); } finally { setBusy(false); } + const start = async () => { + setBusy(true); setError(null); + try { + const { document } = await formsSend("/api/forms/documents", "POST", { dealer_id: dealerId, customer_id: customerId, + ...pick, lead_id: pick.lead_id || null }); + await loadDocs(); setOpen(document.id); + } catch (e) { setError(e.message); } finally { setBusy(false); } }; - const doReview = () => run(async () => setReview(await formsSend("/api/forms/documents", "POST", body("review")))); - const doPreview = () => run(() => openPdf("/api/forms/documents", { method: "POST", body: body("preview") })); - const doSave = () => run(async () => { - await formsSend("/api/forms/documents", "POST", body("save")); - setReview(null); setNotice("Saved on the customer. Download or print it below."); await loadDocs(); - }); - const fileOf = (d, print) => run(() => openPdf(`/api/forms/documents/${d.id}?dealer_id=${dealerId}${print ? "&print=1" : ""}`, - print ? {} : { download: `${d.name}.pdf` }).then(loadDocs)); - const changePick = (patch) => { setPick((p) => ({ ...p, ...patch })); setReview(null); }; return (

Documents

{error && {error}} - {notice && {notice}} - Lead (opportunity) - changePick({ lead_id: e.target.value })}> + setPick({ ...pick, lead_id: e.target.value })}> {leads.map((l) => )} Form - changePick({ template_id: e.target.value })}> + setPick({ ...pick, template_id: e.target.value })}> {templates.map((t) => )} Trade-ins {trades.length ? trades.map((t) => ( changePick({ trade_in_ids: e.target.checked ? [...pick.trade_in_ids, t._id] + onChange={(e) => setPick({ ...pick, trade_in_ids: e.target.checked ? [...pick.trade_in_ids, t._id] : pick.trade_in_ids.filter((x) => x !== t._id) })} /> )) :
None on file
}
-
- -
+ - {review && ( -
-
- {review.template.name} (v{review.template.version}) - {review.trade_ins > 0 && {review.trade_ins} trade-in(s)} -
- {review.missing_required.length > 0 && ( - - Required and empty: {review.missing_required.join(", ")}. Fill them on the customer, the - lead or the trade-in first - nothing is made up. - - )} - - - - {review.rows.map((r, i) => ( - - - - - ))} - -
Where on the formFilled withValue
{r.where}{r.label}{r.required && required}{r.hidden ? managers only - : r.missing ? missing - left blank : r.value}
-
- - -
-
- )} - -
Saved copies
+
Forms for this customer
{!docs ? : !docs.length ?

None yet.

: ( - - +
FormLeadMade byMissing when made
+ - {docs.map((d) => ( - - - - - - - - ))} + {docs.map((d) => { + const lead = d.lead_id && leads.find((l) => l._id === d.lead_id); + return ( + !d.removed && setOpen(d.id)}> + + + + + + ); + })}
FormLeadStartedStatus
{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" + ? Completed : Draft}
)} + {open && setOpen(null)} onChanged={loadDocs} />}
); } diff --git a/aidmvcs-be-dev/app/dealer/forms/[id]/page.js b/aidmvcs-be-dev/app/dealer/forms/[id]/page.js new file mode 100644 index 00000000..85c34e8e --- /dev/null +++ b/aidmvcs-be-dev/app/dealer/forms/[id]/page.js @@ -0,0 +1,301 @@ +"use client"; +// The form builder (SOW v6 Deliverable K; the user, 13 Nov 2026: "an editable form ... they will be able to change +// everything, like the applications that let you create your own forms"). The form read from the dealer's PDF opens +// here as an on-screen form: click any field to change it (label, type, width, required, choices, the CRM value that +// fills it), move / copy / delete it, add fields and sections, rename the form. "Preview PDF" shows how it will print. +// Saving a form already used for customers makes a new version (their copies keep the one they were made with). +// The "Settings" tab: category, on / off, department, roles, lead statuses, how long filled copies are kept. +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Alert, Badge, Button, ButtonGroup, Col, Dropdown, Form, Nav, Row, Spinner } from "react-bootstrap"; +import { useUser } from "../../context/UserContext"; +import { formsGet, formsSend, openPdf } from "../formsApi"; +import FormView from "../components/FormView"; +import { FIELD_TYPES, INPUT_TYPES, REPEATS, WIDTHS, newId } from "../../../lib/forms/schema"; + +const blankField = (type) => ({ id: newId(), type, label: type === "heading" || type === "paragraph" ? "" : FIELD_TYPES[type], + required: false, width: "full", options: type === "select" || type === "radio" ? ["Option 1", "Option 2"] : [], + source: null, help: null, text: type === "heading" ? "Heading" : type === "paragraph" ? "Text" : null }); + +function SourceSelect({ sources, value, onChange, repeat }) { + const groups = useMemo(() => { + const out = new Map(); + for (const s of sources) { + if (!out.has(s.group)) out.set(s.group, []); + out.get(s.group).push(s); + } + return [...out.entries()]; + }, [sources]); + return ( + onChange(e.target.value || null)}> + + {groups.map(([group, items]) => ( + + {items.map((s) => )} + + ))} + + ); +} + +export default function FormBuilderPage() { + const { id } = useParams(); + const router = useRouter(); + const { dealerParent } = useUser(); + const dealerId = dealerParent?.id; + const [meta, setMeta] = useState(null); + const [schema, setSchema] = useState(null); + const [settings, setSettings] = useState(null); + const [tab, setTab] = useState("form"); + const [selected, setSelected] = useState(null); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const base = `/api/forms/templates/${id}`; + + const apply = (d) => { + setMeta(d); + const t = d.template; + setSchema(t.schema || { title: t.name, description: null, sections: [{ id: newId("s"), title: "", repeat: null, fields: [] }] }); + setSettings({ name: t.name, category: t.category, active: t.active, departments: t.departments, role_ids: t.role_ids, + statuses: t.statuses, retention_days: t.retention_days ?? "" }); + setDirty(false); + }; + const load = useCallback(async () => { + if (!dealerId) return; + try { apply(await formsGet(`${base}?dealer_id=${dealerId}`)); } catch (e) { setError(e.message); } + }, [base, dealerId]); + useEffect(() => { load(); }, [load]); + + const run = async (fn, done) => { + setBusy(true); setError(null); setNotice(null); + try { await fn(); if (done) setNotice(done); } catch (e) { setError(e.message); } finally { setBusy(false); } + }; + const edit = (fn) => { setSchema((s) => fn(structuredClone(s))); setDirty(true); }; + const where = (s, fieldId) => { + for (const sec of s.sections) { const i = sec.fields.findIndex((f) => f.id === fieldId); if (i >= 0) return [sec, i]; } + return [null, -1]; + }; + const field = useMemo(() => { + if (!schema || !selected) return null; + for (const sec of schema.sections) { const f = sec.fields.find((x) => x.id === selected); if (f) return { f, sec }; } + return null; + }, [schema, selected]); + const setField = (patch) => edit((s) => { const [sec, i] = where(s, selected); if (sec) Object.assign(sec.fields[i], patch); return s; }); + const moveField = (d) => edit((s) => { + const [sec, i] = where(s, selected); + if (!sec) return s; + const si = s.sections.indexOf(sec); + if (i + d >= 0 && i + d < sec.fields.length) [sec.fields[i], sec.fields[i + d]] = [sec.fields[i + d], sec.fields[i]]; + else if (s.sections[si + d]) { // across into the next / previous section + const [f] = sec.fields.splice(i, 1); + if (d < 0) s.sections[si - 1].fields.push(f); else s.sections[si + 1].fields.unshift(f); + } + return s; + }); + const addField = (sectionId, type) => { + const f = blankField(type); + edit((s) => { s.sections.find((x) => x.id === sectionId).fields.push(f); return s; }); + setSelected(f.id); + }; + + const save = () => run(async () => { + const d = await formsSend(base, "PATCH", { dealer_id: dealerId, schema }); + apply({ ...meta, template: d.template }); + }, "Form saved."); + const saveSettings = () => run(async () => { + const d = await formsSend(base, "PATCH", { dealer_id: dealerId, ...settings, + retention_days: settings.retention_days === "" ? null : Number(settings.retention_days) }); + setMeta((m) => ({ ...m, template: d.template })); + }, "Settings saved."); + const reread = (file) => run(async () => { + if (dirty && !window.confirm("Reading a new PDF replaces the unsaved changes here. Go on?")) return; + const fd = new FormData(); fd.set("dealer_id", dealerId); fd.set("file", file); + const d = await formsSend(base, "POST", fd); + apply({ ...meta, template: d.template }); + }, "The new PDF was read into a new version. Check it and save."); + + if (!meta || !schema) { + return
+ {error ? {error} : }
; + } + const t = meta.template; + const can = meta.can_manage; + const toggle = (k, v) => setSettings((s) => ({ ...s, [k]: s[k].includes(v) ? s[k].filter((x) => x !== v) : [...s[k], v] })); + + return ( +
+
+ +

{t.name} v{t.current_version} + {" "}{t.active ? On : Off}

+ {t.source_filename && } + + {can && } +
+
+ {error && {error}} + {notice && {notice}} + {!t.active && This form is off: staff can't use it + yet. Check every field, save, then switch it on in Settings.} + + + {tab === "form" && ( + + +
+ {can && ( + + Form title (printed at the top) + edit((s) => ({ ...s, title: e.target.value }))} /> + Description (optional) + edit((s) => ({ ...s, description: e.target.value }))} /> + + )} + {schema.sections.map((sec, si) => ( +
+ {can && ( +
+ edit((s) => { s.sections[si].title = e.target.value; return s; })} /> + edit((s) => { s.sections[si].repeat = e.target.checked ? "trade_in" : null; return s; })} /> + + + + + +
+ )} + + {can && ( + + + Add field + + {Object.entries(FIELD_TYPES).map(([k, label]) => addField(sec.id, k)}>{label})} + + + )} +
+ ))} + {can && } +
+ + +
+ {!field ?

Click a field on the form to change it.

: ( + <> +
+ {FIELD_TYPES[field.f.type]} + {can && ( + + + + + + + )} +
+
+ Type + { + const type = e.target.value; + setField({ type, ...(type === "heading" || type === "paragraph" ? { text: field.f.text || field.f.label, source: null, required: false } : {}), + ...((type === "select" || type === "radio") && !field.f.options.length ? { options: ["Option 1", "Option 2"] } : {}) }); + }}>{Object.entries(FIELD_TYPES).map(([k, l]) => )} + {field.f.type === "heading" || field.f.type === "paragraph" ? ( + Wording + setField({ text: e.target.value })} /> + ) : ( + <> + Label + setField({ label: e.target.value })} /> + Filled from the CRM + setField({ source })} /> + Staff can still change it on the form. + {(field.f.type === "select" || field.f.type === "radio") && ( + Choices (one per line) + setField({ options: e.target.value.split("\n") })} /> + )} + Help text (optional) + setField({ help: e.target.value })} /> + setField({ required: e.target.checked })} /> + + )} + {INPUT_TYPES.includes(field.f.type) && ( + Width + setField({ width: e.target.value })}> + {Object.entries(WIDTHS).map(([k, l]) => )} + )} +
+ + )} +
+ +
+ )} + + {tab === "settings" && settings && ( +
+
+ + Name in the library + setSettings({ ...settings, name: e.target.value })} /> + Category + setSettings({ ...settings, category: e.target.value })}> + {meta.categories.map((c) => )} + Keep filled forms (days) + setSettings({ ...settings, retention_days: e.target.value })} /> + + setSettings({ ...settings, active: e.target.checked })} /> + +
Department (none = both)
+ {["sales", "service"].map((d) => toggle("departments", d)} />)}
+
Roles that can use it (none = every role)
+ {meta.roles.length ? meta.roles.map((r) => toggle("role_ids", r.id)} />) : No staff roles set up.}
+
Available when the lead is (none = any status)
+ {meta.statuses.map((s) => toggle("statuses", s)} />)}
+
+ {can && } +
+ )} + + {tab === "history" && ( +
+ {can && ( + Read a new PDF of this form (it becomes a new version + for you to check; forms already filled keep theirs) + { if (e.target.files?.[0]) reread(e.target.files[0]); e.target.value = ""; }} /> + )} +
Versions
+
    {t.version_list.map((v) =>
  • v{v.version}{v.version === t.current_version ? " (in use)" : ""} · + {" "}{v.created_by || "Staff"}, {new Date(v.created_at).toLocaleString()}{v.from_pdf ? ` · from ${v.from_pdf}` : ""}
  • )}
+
History
+
    {t.history.map((h, i) =>
  • {new Date(h.at).toLocaleString()} · {h.by_name || "Staff"} · + {" "}{h.action}{h.detail ? `: ${h.detail}` : ""}
  • )}
+
+ )} +
+
+ ); +} diff --git a/aidmvcs-be-dev/app/dealer/forms/components/FormView.js b/aidmvcs-be-dev/app/dealer/forms/components/FormView.js new file mode 100644 index 00000000..370c2bd8 --- /dev/null +++ b/aidmvcs-be-dev/app/dealer/forms/components/FormView.js @@ -0,0 +1,87 @@ +"use client"; +// The on-screen form (SOW v6 Deliverable K): one renderer for filling a form for a customer, for viewing a completed +// one, and for the builder's canvas (mode "design": fields can be picked, not typed in). A section set to repeat per +// trade-in shows once for each trade-in picked. Values: data[fieldId] or data[`${fieldId}#n`] in a repeated section. +import { Badge, Form } from "react-bootstrap"; + +const COL = { full: 12, half: 6, third: 4 }; +export const key = (fieldId, n) => (n == null ? fieldId : `${fieldId}#${n}`); + +function Input({ field, value, onChange, readOnly, hidden }) { + if (hidden) return ; + const common = { size: "sm", disabled: readOnly, value: value ?? "", onChange: (e) => onChange(e.target.value) }; + switch (field.type) { + case "textarea": return ; + case "number": return ; + case "money": return
$ +
; + case "date": return ; + case "email": return ; + case "phone": return ; + case "select": return ( + + {field.options.map((o) => )}); + case "radio": return ( +
{field.options.map((o) => onChange(o)} />)}
); + case "signature": return ; + default: return ; + } +} + +export default function FormView({ schema, data = {}, onChange = () => {}, mode = "fill", trades = 0, hidden = [], + prefilled = [], selected = null, onSelect = () => {} }) { + const design = mode === "design"; + const readOnly = mode === "view" || design; + return ( +
+

{schema.title}

+ {schema.description &&

{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) => ( +
+ {(s.title || copies > 1) && ( +
+ {s.title || "Trade-in"}{copies > 1 ? ` ${n + 1}` : ""} + {design && s.repeat === "trade_in" && (repeats for each trade-in)} +
+ )} +
+ {s.fields.map((f) => { + const k = key(f.id, s.repeat === "trade_in" ? n : null); + const pick = design ? { role: "button", onClick: () => onSelect(f.id), + style: { outline: selected === f.id ? "2px solid #2f6fdf" : "1px dashed #d5dbe5", outlineOffset: 2, + borderRadius: 4, cursor: "pointer" } } : {}; + if (f.type === "heading") return
{f.text}
; + if (f.type === "paragraph") return
{f.text}
; + if (f.type === "checkbox") { + return ( +
+ onChange(k, e.target.checked)} /> +
+ ); + } + return ( +
+ + {f.label}{f.required && *} + {design && f.source && CRM} + {!design && prefilled.includes(k) && (from the CRM)} + + onChange(k, v)} /> + {f.help && {f.help}} +
+ ); + })} + {design && !s.fields.length &&
No fields in this section yet.
} +
+
+ )); + })} +
+ ); +} diff --git a/aidmvcs-be-dev/app/dealer/forms/components/TemplateEditor.js b/aidmvcs-be-dev/app/dealer/forms/components/TemplateEditor.js deleted file mode 100644 index 5fd3d3d1..00000000 --- a/aidmvcs-be-dev/app/dealer/forms/components/TemplateEditor.js +++ /dev/null @@ -1,205 +0,0 @@ -"use client"; -// One form of the Forms Library: who can use it and when (G-064), what fills each field (G-065), its versions and -// change history. A fillable PDF lists its own fields: pick what fills each one. A flat PDF gets boxes placed by page -// and position (points from the bottom-left corner; "Show grid" opens the page with a 50-point grid to read them off). -// "Preview mapping" opens the blank form with each field / box labelled - no customer data. -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, Badge, Button, Col, Form, Modal, Row, Spinner, Table } from "react-bootstrap"; -import { formsGet, formsSend, openPdf } from "../formsApi"; - -function SourceSelect({ sources, value, onChange, disabled }) { - const groups = useMemo(() => { - const out = new Map(); - for (const s of sources) { if (!out.has(s.group)) out.set(s.group, []); out.get(s.group).push(s); } - return [...out.entries()]; - }, [sources]); - return ( - onChange(e.target.value)}> - - {groups.map(([group, items]) => ( - - {items.map((s) => )} - - ))} - - ); -} - -export default function TemplateEditor({ dealerId, id, canManage, categories, onHide }) { - const [data, setData] = useState(null); - const [settings, setSettings] = useState(null); - const [mapping, setMapping] = useState([]); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - const base = `/api/forms/templates/${id}`; - - const apply = (d) => { - setData(d); - const t = d.template; - setSettings({ name: t.name, category: t.category, active: t.active, departments: t.departments, - role_ids: t.role_ids, statuses: t.statuses, retention_days: t.retention_days ?? "" }); - if (t.fillable) { - const byField = new Map(t.mapping.filter((m) => m.field).map((m) => [m.field, m])); - setMapping(t.fields.map((f) => ({ field: f.name, type: f.type, source: byField.get(f.name)?.source || "", - required: Boolean(byField.get(f.name)?.required) }))); - } else { - setMapping(t.mapping.map((m) => ({ ...m }))); - } - }; - const load = useCallback(async () => { - try { apply(await formsGet(`${base}?dealer_id=${dealerId}`)); } catch (e) { setError(e.message); } - }, [base, dealerId]); - useEffect(() => { load(); }, [load]); - - const run = async (fn, done) => { - setBusy(true); setError(null); setNotice(null); - try { await fn(); if (done) setNotice(done); } catch (e) { setError(e.message); } finally { setBusy(false); } - }; - const saveSettings = () => run(async () => { - const d = await formsSend(base, "PATCH", { dealer_id: dealerId, ...settings, - retention_days: settings.retention_days === "" ? null : Number(settings.retention_days) }); - setData((x) => ({ ...x, template: d.template })); - }, "Settings saved."); - const saveMapping = () => run(async () => { - const rows = data.template.fillable ? mapping.filter((m) => m.source).map(({ field, source, required }) => ({ field, source, required })) - : mapping; - const d = await formsSend(base, "PATCH", { dealer_id: dealerId, mapping: rows }); - apply({ ...data, template: d.template }); - }, "Mapping saved."); - const newVersion = (file) => run(async () => { - const fd = new FormData(); fd.set("dealer_id", dealerId); fd.set("file", file); - const d = await formsSend(base, "POST", fd); - apply({ ...data, template: d.template }); - if (d.dropped?.length) setNotice(`New version saved. Not on the new PDF any more (map again): ${d.dropped.join(", ")}`); - }, "New version saved; the mapping was carried over."); - const pdf = (q) => run(() => openPdf(`${base}/pdf?dealer_id=${dealerId}${q}`)); - - if (!data || !settings) { - return - {error ? {error} : }; - } - const t = data.template; - const ro = !canManage || busy; - const toggle = (key, value) => setSettings((s) => ({ ...s, [key]: s[key].includes(value) - ? s[key].filter((x) => x !== value) : [...s[key], value] })); - const setRow = (i, patch) => setMapping((rows) => rows.map((r, j) => (j === i ? { ...r, ...patch } : r))); - - return ( - - - {t.name} v{t.current_version} · - {t.fillable ? " fillable PDF" : " flat PDF"} - - - {error && {error}} - {notice && {notice}} - -
Who can use it, and when
- - Name - setSettings({ ...settings, name: e.target.value })} /> - Category - setSettings({ ...settings, category: e.target.value })}> - {categories.map((c) => )} - Keep filled copies (days) - setSettings({ ...settings, retention_days: e.target.value })} /> - - setSettings({ ...settings, active: e.target.checked })} /> - - -
Department (none = both)
- {["sales", "service"].map((d) => toggle("departments", d)} />)} -
Roles (none = every role)
- {data.roles.length ? data.roles.map((r) => toggle("role_ids", r.id)} />) - : No staff roles set up.} -
Available when the lead is (none = any status)
- {data.statuses.map((s) => toggle("statuses", s)} />)} -
- {canManage &&
} - -
-
What fills each field
- - {!t.fillable && } -
-

- {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. -

- - - {t.fillable ? - : } - - - {mapping.map((m, i) => (t.fillable ? ( - - - - - - ) : ( - - {["page", "x", "y", "size"].map((k) => ( - - ))} - - - - - - )))} - -
PDF fieldFilled withRequired
PagexySizeLabelFilled withRequired
{m.field} {m.type?.replace("Field", "")} setRow(i, { source, required: source ? m.required : false })} /> setRow(i, { required: e.target.checked })} />
setRow(i, { [k]: e.target.value === "" ? "" : Number(e.target.value) })} /> setRow(i, { label: e.target.value })} /> setRow(i, { source })} /> setRow(i, { required: e.target.checked })} />
- {canManage && ( -
- {!t.fillable && } - -
- )} - -
Versions
- {canManage && ( - - Replace the PDF with a new version (the older ones are kept; copies already - filled keep their version) - { if (e.target.files?.[0]) newVersion(e.target.files[0]); e.target.value = ""; }} /> - - )} - - - {t.version_list.map((v) => ( - - - - - - - ))} - -
v{v.version} {v.version === t.current_version && in use}{v.filename}{v.fillable ? "fillable" : "flat"}{v.mapped} mapped{v.uploaded_by || "Staff"}, {new Date(v.uploaded_at).toLocaleString()}
-
- Change history -
    - {t.history.map((h, i) =>
  • {new Date(h.at).toLocaleString()} · {h.by_name || "Staff"} · {h.action} - {h.detail ? `: ${h.detail}` : ""}
  • )} -
-
-
-
- ); -} diff --git a/aidmvcs-be-dev/app/dealer/forms/page.js b/aidmvcs-be-dev/app/dealer/forms/page.js index 050a50ab..7a305898 100644 --- a/aidmvcs-be-dev/app/dealer/forms/page.js +++ b/aidmvcs-be-dev/app/dealer/forms/page.js @@ -1,22 +1,22 @@ "use client"; -// Forms & Documents Library (SOW v6 Deliverable K, gap G-064): the dealership's own PDFs - buyer's order, trade-in -// appraisal, test drive, credit application, We Owe, delivery checklist, privacy... Upload, name, categorize, switch -// on / off, replace with a new version, say who can use each one and when, and map what fills each field -// (components/TemplateEditor.js). Staff fill the forms from a customer's Documents tab. +// Forms Library (SOW v6 Deliverable K, gap G-064; the user, 13 Nov 2026): the dealership's forms as on-screen forms - +// buyer's order, trade-in appraisal, test drive, credit application, We Owe, delivery checklist, privacy... A new form +// is read from the dealer's PDF (or started blank) and opens in the form builder ([id]/page.js) to check and edit. +// Staff fill the forms from a customer's Documents tab. import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { Alert, Badge, Button, Form, Modal, Spinner, Table } from "react-bootstrap"; import { useUser } from "../context/UserContext"; import { formsGet, formsSend } from "./formsApi"; -import TemplateEditor from "./components/TemplateEditor"; export default function FormsLibraryPage() { const { dealerParent, loadingParent } = useUser(); + const router = useRouter(); const dealerId = dealerParent?.id; const [data, setData] = useState(null); const [error, setError] = useState(null); const [upload, setUpload] = useState(null); // { name, category, file } const [busy, setBusy] = useState(false); - const [open, setOpen] = useState(null); // template id const load = useCallback(async () => { if (!dealerId) return; @@ -30,9 +30,9 @@ export default function FormsLibraryPage() { try { const fd = new FormData(); fd.set("dealer_id", dealerId); fd.set("name", upload.name); fd.set("category", upload.category); - fd.set("file", upload.file); + if (upload.file) fd.set("file", upload.file); else fd.set("blank", "true"); const { template } = await formsSend("/api/forms/templates", "POST", fd); - setUpload(null); await load(); setOpen(template.id); + router.push(`/dealer/forms/${template.id}`); } catch (e) { setError(e.message); } finally { setBusy(false); } }; @@ -46,32 +46,32 @@ export default function FormsLibraryPage() {

Forms Library

{data?.can_manage && ( )}
{error && {error}}

- 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 ? ( - No forms yet.{data.can_manage ? " Upload your first PDF." : ""} + No forms yet.{data.can_manage ? " Click New form." : ""} ) : data && (
- + {data.templates.map((t) => ( - setOpen(t.id)}> + router.push(`/dealer/forms/${t.id}`)}> - - - + +
FormCategoryTypeVersionFields mapped
FormCategoryFieldsVersion Who / whenStatus
{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() { setUpload(null)} centered> - Upload a form + New form {upload && ( <> @@ -98,7 +98,7 @@ export default function FormsLibraryPage() { setUpload({ ...upload, category: e.target.value })}> {(data?.categories || []).map((c) => )} - PDF (fillable or not, up to 15 MB) + The form's PDF (up to 15 MB) - leave empty to start a blank form setUpload({ ...upload, file: e.target.files?.[0] || null })} /> @@ -106,16 +106,12 @@ export default function FormsLibraryPage() { - - {open && ( - { setOpen(null); load(); }} /> - )} ); } diff --git a/aidmvcs-be-dev/app/lib/forms/access.js b/aidmvcs-be-dev/app/lib/forms/access.js index b2ac8032..ae05eb56 100644 --- a/aidmvcs-be-dev/app/lib/forms/access.js +++ b/aidmvcs-be-dev/app/lib/forms/access.js @@ -37,9 +37,10 @@ export const actorOf = (user) => ({ by: user?._id || null, by_name: user?.name | // One form as the library lists it. export function templateSummary(t) { const v = (t.versions || []).find((x) => x.version === t.current_version) || {}; + const fields = (v.schema?.sections || []).reduce((n, sec) => n + sec.fields.filter((f) => !['heading', 'paragraph'] + .includes(f.type)).length, 0); return { id: String(t._id), name: t.name, category: t.category, active: t.active, departments: t.departments || [], role_ids: (t.role_ids || []).map(String), statuses: t.statuses || [], retention_days: t.retention_days ?? null, - current_version: t.current_version, versions: (t.versions || []).length, fillable: Boolean(v.fillable), - mapped: (v.mapping || []).length, required: (v.mapping || []).filter((m) => m.required).length, - updated_at: t.updatedAt }; + current_version: t.current_version, versions: (t.versions || []).length, fields, ready: Boolean(v.schema), + from_pdf: Boolean(v.source_file_id), updated_at: t.updatedAt }; } diff --git a/aidmvcs-be-dev/app/lib/forms/documents.js b/aidmvcs-be-dev/app/lib/forms/documents.js new file mode 100644 index 00000000..8ee4efb0 --- /dev/null +++ b/aidmvcs-be-dev/app/lib/forms/documents.js @@ -0,0 +1,67 @@ +// Filled forms (SOW v6 Deliverable K, gap G-066): which data keys a form has, the CRM values that start it, what is +// missing, and what a person without finance rights may see. Server side. +import { INPUT_TYPES, dataKey } from './schema.js'; +import { isSensitive } from './sources.js'; + +// Every input of the form with its data key; a section repeated per trade-in has one copy per trade-in (at least 1). +export function slots(schema, trades) { + const out = []; + for (const s of schema.sections) { + const copies = s.repeat === 'trade_in' ? Math.max(1, trades) : 1; + for (let n = 0; n < copies; n += 1) { + for (const f of s.fields) { + if (!INPUT_TYPES.includes(f.type)) continue; + out.push({ key: dataKey(f.id, s.repeat === 'trade_in' ? n : null), field: f, section: s, copy: n }); + } + } + } + return out; +} + +/** The CRM values a new copy starts with: { data, prefilled } (empty values are left out - nothing made up). */ +export function prefill(schema, { values, trades }) { + const data = {}; const prefilled = []; + for (const { key, field, section, copy } of slots(schema, trades.length)) { + if (!field.source) continue; + const v = section.repeat === 'trade_in' && field.source.startsWith('trade.') ? trades[copy]?.[field.source] : values[field.source]; + if (v == null || v === '') continue; + data[key] = field.type === 'checkbox' ? /^(yes|true|x|1)$/i.test(String(v)) : String(v); + prefilled.push(key); + } + return { data, prefilled }; +} + +const hiddenFor = (schema, trades, finance) => (finance ? new Set() + : new Set(slots(schema, trades).filter((s) => isSensitive(s.field.source)).map((s) => s.key))); + +/** Values sent by the screen, kept only for the form's own keys; payoff / lienholder only from managers. */ +export function cleanData(schema, trades, input, { finance, previous = {} }) { + const hidden = hiddenFor(schema, trades, finance); + const out = { ...previous }; + for (const { key, field } of slots(schema, trades)) { + if (hidden.has(key) || !(key in (input || {}))) continue; + const v = input[key]; + out[key] = field.type === 'checkbox' ? Boolean(v) : v == null ? '' : String(v).slice(0, 2000); + } + return out; +} + +export function missingRequired(schema, trades, data) { + return slots(schema, trades).filter(({ key, field }) => field.required && field.type !== 'checkbox' + && !String(data[key] ?? '').trim()).map(({ field, section, copy }) => (section.repeat === 'trade_in' && trades > 1 + ? `${field.label} (trade-in ${copy + 1})` : field.label)); +} + +/** The values a person may see: payoff / lienholder blanked for anyone without finance rights. */ +export function visibleData(schema, trades, data, finance) { + const hidden = hiddenFor(schema, trades, finance); + return { data: Object.fromEntries(Object.entries(data || {}).filter(([k]) => !hidden.has(k))), hidden: [...hidden] }; +} + +// One filled form as a customer's list shows it. +export 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, status: d.status, + created_at: d.created_at, created_by: d.created_by_name, completed_at: d.completed_at, + completed_by: d.completed_by_name, retain_until: d.retain_until, removed: Boolean(d.removed_at) }; +} diff --git a/aidmvcs-be-dev/app/lib/forms/extract.js b/aidmvcs-be-dev/app/lib/forms/extract.js new file mode 100644 index 00000000..150a85e8 --- /dev/null +++ b/aidmvcs-be-dev/app/lib/forms/extract.js @@ -0,0 +1,216 @@ +// The first draft of an on-screen form, read from the dealer's PDF (SOW v6 Deliverable K; the user, 13 Nov 2026: +// "they can upload a PDF, we extract all the information, create a replica of the form and give them an editable +// format"). The dealer then corrects it in the builder - this is a starting point, not the final form. +// +// Read from a digital PDF (text inside it, made by Word / Acrobat / a DMS): +// - the PDF's own form fields (a fillable PDF), labelled by the text next to / above them +// - blanks in the text: "Buyer Name: ________", several on one line ("Date ____ Stock # ____"), "Label:" with +// nothing after it, checkboxes ("[ ] Yes", "☐ Financing") +// - headings (larger or ALL-CAPS short lines) start sections; the biggest line on page 1 is the title +// - every other line is kept as wording (a text block), so the form's own text isn't lost +// A field's type (date, money, phone, signature...) and the CRM value that fills it are guessed from its label; the +// dealer checks both. A scanned PDF (a picture, no text) can't be read: it is refused with a plain message. +import { PDFDocument } from 'pdf-lib'; +import { getDocumentProxy } from 'unpdf'; +import { newId } from './schema.js'; + +const MAX_BYTES = 15 * 1024 * 1024; +const BLANK = /_{3,}|\.{6,}/; +const BLANK_FIELD = /([A-Za-z#][^_:$]{0,70}?)\s*:?\s*\$?\s*(?:_{3,}|\.{6,})/g; +const CHECKBOX = /(?:\[\s?\]|☐|□|❑)\s*([^[☐□❑]{1,80})/g; + +const TYPE_RULES = [ + [/sign(ature)?\b/i, 'signature'], [/\b(date|dob|birth|delivered on)\b/i, 'date'], + [/(price|amount|fee|tax|total|balance|payment|payoff|down|rebate|cost|\$|acv|allowance|value)/i, 'money'], + [/e-?mail/i, 'email'], [/(phone|cell|mobile|tel\b|fax)/i, 'phone'], + [/\b(miles|mileage|odometer|year|zip|postal|term|months)\b/i, 'number'], +]; +const SOURCE_RULES = [ + [/^(buyer|customer|purchaser|client)('?s)?\s*(full\s*)?name$|^name$/i, 'customer.full_name'], + [/first\s*name/i, 'customer.first_name'], [/last\s*name|surname/i, 'customer.last_name'], + [/(street|^address|home address|buyer'?s? address)/i, 'customer.address_line'], [/^city/i, 'customer.city'], + [/^state/i, 'customer.state'], [/^(zip|postal)/i, 'customer.zip'], [/e-?mail/i, 'customer.email'], + [/(phone|cell|mobile)/i, 'customer.phone'], [/^(vehicle\s*)?vin|serial/i, 'vehicle.vin'], + [/^(vehicle\s*)?year$/i, 'vehicle.year'], [/^(vehicle\s*)?make$/i, 'vehicle.make'], [/^(vehicle\s*)?model$/i, 'vehicle.model'], + [/stock/i, 'vehicle.stock_number'], [/sales\s*(person|consultant|rep)/i, 'staff.salesperson'], + [/sales\s*manager/i, 'staff.sales_manager'], [/dealer(ship)?\s*name/i, 'dealer.name'], +]; + +const typeFor = (label) => (TYPE_RULES.find(([re]) => re.test(label)) || [null, 'text'])[1]; +const sourceFor = (label) => (SOURCE_RULES.find(([re]) => re.test(label.trim())) || [null, null])[1]; +const tidy = (s) => String(s || '').replace(/[_.]{3,}/g, ' ').replace(/\s+/g, ' ').replace(/[\s:–-]+$/, '').trim(); +const humanize = (name) => tidy(String(name).replace(/[\[\].]+\d*/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ')).replace(/^\w/, (c) => c.toUpperCase()); + +function field(label, type = null, extra = {}) { + const l = tidy(label).slice(0, 200); + const t = type || typeFor(l); + const source = t === 'signature' ? null : sourceFor(l); + return { id: newId(), type: t, label: l, required: false, width: 'full', options: [], source, help: null, text: null, + ...extra }; +} + +// Text items -> lines (same page, same baseline), left to right. +function toLines(items) { + const lines = []; + for (const it of items) { + if (!it.str.trim()) continue; + const line = lines.find((l) => l.page === it.page && Math.abs(l.y - it.y) <= 2.5); + if (line) { line.parts.push(it); line.size = Math.max(line.size, it.size); } + else lines.push({ page: it.page, y: it.y, size: it.size, parts: [it] }); + } + for (const l of lines) { + l.parts.sort((a, b) => a.x - b.x); + l.x = l.parts[0].x; + l.text = l.parts.reduce((acc, p, i) => { + if (!i) return p.str; + const prev = l.parts[i - 1]; + const gap = p.x - (prev.x + prev.w); + return acc + (gap > 12 ? ' ' : gap > 1 ? ' ' : '') + p.str; + }, '').replace(/\s+$/, ''); + } + return lines.sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x); +} + +async function readText(buffer) { + const pdf = await getDocumentProxy(new Uint8Array(buffer)); + const items = []; + for (let n = 1; n <= pdf.numPages; n += 1) { + const page = await pdf.getPage(n); + const content = await page.getTextContent(); + for (const it of content.items) { + if (typeof it.str !== 'string') continue; + items.push({ page: n, str: it.str, x: it.transform[4], y: it.transform[5], w: it.width || 0, + size: Math.round(Math.abs(it.transform[0]) || Math.abs(it.transform[3]) || 10) }); + } + } + return { items, pages: pdf.numPages }; +} + +// The PDF's own form fields (a fillable PDF), with their page and position. +async function readAcroFields(pdfDoc) { + const pages = pdfDoc.getPages(); + const out = []; + for (const f of pdfDoc.getForm().getFields()) { + const widget = f.acroField.getWidgets()[0]; + if (!widget) continue; + const rect = widget.getRectangle(); + const ref = widget.P(); + let page = pages.findIndex((p) => p.ref === ref) + 1; + if (page < 1) page = 1; + const kind = f.constructor.name; + const type = kind === 'PDFCheckBox' ? 'checkbox' : kind === 'PDFDropdown' || kind === 'PDFOptionList' ? 'select' + : kind === 'PDFRadioGroup' ? 'radio' : kind === 'PDFSignature' ? 'signature' : null; + let options = []; + try { if (type === 'select' || type === 'radio') options = f.getOptions(); } catch { options = []; } + out.push({ name: f.getName(), page, x: rect.x, y: rect.y, w: rect.width, h: rect.height, type, options }); + } + return out; +} + +/** + * @returns {Promise<{schema: object, fillable: boolean, pages: number, found: {fields: number, sections: number}}>} + */ +export async function extractForm(buffer, { fallbackTitle = 'Form' } = {}) { + if (!buffer?.length) throw Object.assign(new Error('The file is empty'), { status: 400 }); + if (buffer.length > MAX_BYTES) throw Object.assign(new Error('The PDF is larger than 15 MB'), { status: 400 }); + if (buffer.subarray(0, 5).toString() !== '%PDF-') throw Object.assign(new Error('That file is not a PDF'), { status: 400 }); + let pdfDoc; + try { pdfDoc = await PDFDocument.load(buffer, { updateMetadata: false }); } catch (e) { + throw Object.assign(new Error(/encrypt/i.test(e?.message || '') ? 'The PDF is password-protected: upload an unlocked copy' + : 'The PDF could not be read'), { status: 400 }); + } + const { items, pages } = await readText(buffer); + const acro = await readAcroFields(pdfDoc); + const textChars = items.reduce((n, it) => n + it.str.trim().length, 0); + if (textChars < 20 && !acro.length) { + throw Object.assign(new Error('This PDF is a scan (a picture of the page), so its text can\'t be read. Upload the ' + + 'digital version of the form (from Word, Acrobat or your DMS), or start a blank form and type it in.'), { status: 422 }); + } + const lines = toLines(items); + const sizes = lines.map((l) => l.size).sort((a, b) => a - b); + const body = sizes[Math.floor(sizes.length / 2)] || 10; + const first = lines.filter((l) => l.page === 1).sort((a, b) => b.size - a.size || b.y - a.y)[0]; + const title = first && first.size > body * 1.2 ? tidy(first.text) : fallbackTitle; + + // Each AcroForm field's label: text on its row just to the left, else the line just above it. + const used = new Set(); + const acroEntries = acro.map((a) => { + const row = lines.filter((l) => l.page === a.page && Math.abs(l.y - (a.y + a.h / 3)) <= Math.max(6, a.h) + && l.x < a.x).sort((p, q) => q.x - p.x)[0]; + const above = lines.filter((l) => l.page === a.page && l.y > a.y + a.h - 2 && l.y - (a.y + a.h) < 16 + && Math.abs(l.x - a.x) < 120).sort((p, q) => p.y - q.y)[0]; + const near = row || above; + let label = humanize(a.name); + if (near && tidy(near.text).length <= 80 && !BLANK.test(near.text)) { label = tidy(near.text); used.add(near); } + return { page: a.page, y: a.y + a.h, x: a.x, field: field(label, a.type, a.options.length ? { options: a.options } : {}) }; + }); + + // Text lines -> headings, blanks, checkboxes, wording. + const textEntries = []; + for (const l of lines) { + if (l === first && title !== fallbackTitle) continue; + if (used.has(l)) continue; + const t = l.text.trim(); + const isHeading = !BLANK.test(t) && t.length <= 70 && (l.size > body * 1.15 || (t === t.toUpperCase() && /[A-Z]{3}/.test(t) + && t.split(' ').length <= 8 && !t.endsWith(':'))); + if (isHeading) { textEntries.push({ page: l.page, y: l.y, x: l.x, heading: tidy(t) }); continue; } + // Each part of the line (parts are apart by a wide gap): blanks, checkboxes, or "Label:" with nothing after it. + const fields = []; + const parts = t.split(/\s{3,}/); + for (const part of parts) { + const before = fields.length; + for (const m of part.matchAll(BLANK_FIELD)) if (tidy(m[1])) fields.push(field(m[1])); + for (const m of part.matchAll(CHECKBOX)) if (tidy(m[1])) fields.push(field(m[1], 'checkbox')); + if (fields.length === before && /^[A-Za-z][^:]{1,50}:\s*\$?$/.test(part.trim()) && (parts.length > 1 + || part.trim().length <= 40)) fields.push(field(part)); + } + if (fields.length) { + const width = fields.length === 2 ? 'half' : fields.length >= 3 ? 'third' : 'full'; + fields.forEach((f, i) => textEntries.push({ page: l.page, y: l.y, x: l.x + i, field: { ...f, width } })); + continue; + } + textEntries.push({ page: l.page, y: l.y, x: l.x, words: t }); + } + + // In reading order; wording lines next to each other become one text block. + const all = [...acroEntries, ...textEntries].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x); + const sections = [{ id: newId('s'), title: '', repeat: null, fields: [] }]; + let paragraph = null; + for (const e of all) { + const current = sections[sections.length - 1]; + if (e.words) { + if (paragraph && paragraph.page === e.page && paragraph.lastY - e.y < 18) { + paragraph.field.text += ` ${e.words}`; paragraph.lastY = e.y; continue; + } + const f = { id: newId(), type: 'paragraph', label: '', required: false, width: 'full', options: [], source: null, + help: null, text: e.words }; + current.fields.push(f); + paragraph = { field: f, page: e.page, lastY: e.y }; + continue; + } + paragraph = null; + if (e.heading) { + if (!current.fields.length && !current.title) current.title = e.heading; + else sections.push({ id: newId('s'), title: e.heading, repeat: /trade/i.test(e.heading) ? 'trade_in' : null, fields: [] }); + continue; + } + current.fields.push(e.field); + } + for (const s of sections) { + for (const f of s.fields) if (f.type === 'paragraph') f.text = f.text.slice(0, 4000); + if (s.repeat === 'trade_in') { + for (const f of s.fields) { + const k = { year: 'trade.year', make: 'trade.make', model: 'trade.model', vin: 'trade.vin', miles: 'trade.miles', + mileage: 'trade.miles', odometer: 'trade.miles', payoff: 'trade.payoff', lienholder: 'trade.lienholder', + color: 'trade.color', acv: 'trade.acv', allowance: 'trade.offer', trim: 'trade.trim' }; + const hit = Object.keys(k).find((w) => new RegExp(`\\b${w}\\b`, 'i').test(f.label || '')); + if (hit && f.type !== 'paragraph') f.source = k[hit]; + } + } + } + const kept = sections.filter((s) => s.fields.length || s.title); + return { schema: { title, description: null, sections: kept.length ? kept : sections }, fillable: acro.length > 0, + pages, found: { fields: kept.reduce((n, s) => n + s.fields.filter((f) => f.type !== 'paragraph').length, 0), + sections: kept.length } }; +} diff --git a/aidmvcs-be-dev/app/lib/forms/formPdf.js b/aidmvcs-be-dev/app/lib/forms/formPdf.js new file mode 100644 index 00000000..674b0aea --- /dev/null +++ b/aidmvcs-be-dev/app/lib/forms/formPdf.js @@ -0,0 +1,83 @@ +// The PDF of an on-screen form (SOW v6 Deliverable K; the user, 13 Nov 2026: the PDF is "just a reflection" of the +// form - its preview / download). Laid out from the form itself: the dealership's name, the title, each section with +// its fields in rows (full / half / third width), wording as written, checkboxes, signature lines. A section repeated +// per trade-in is printed once for each. Empty values print as a blank line to fill by hand - never made up. +import { renderPdf, INK, MUTED, HEAD } from '../reporting/sourcePerformancePdf.js'; +import { dataKey } from './schema.js'; + +const SPAN = { full: 6, half: 3, third: 2 }; + +function cell(field, value, span = 6) { + if (field.type === 'heading') return { text: field.text || field.label, bold: true, fontSize: 11, color: HEAD, margin: [0, 6, 0, 2] }; + if (field.type === 'paragraph') return { text: field.text, fontSize: 8, color: INK, margin: [0, 2, 0, 4] }; + if (field.type === 'checkbox') { + const on = value === true || /^(true|yes|x|1)$/i.test(String(value ?? '')); + return { text: [{ text: on ? '[X] ' : '[ ] ', bold: true }, field.label], margin: [0, 4, 0, 4] }; + } + const shown = value == null || value === '' ? '' : String(value); + return { + stack: [ + { text: field.label + (field.required ? ' *' : ''), fontSize: 7, color: MUTED }, + { text: shown || ' ', fontSize: 10, margin: [0, 1, 0, 0] }, + { canvas: [{ type: 'line', x1: 0, y1: 1, x2: Math.round((span / 6) * 520) - 14, y2: 1, lineWidth: 0.5, + lineColor: '#9aa1ac' }] }, + ], + margin: [0, field.type === 'signature' ? 14 : 4, 8, 4], + }; +} + +// Fields -> rows of up to 6 units (full = 6, half = 3, third = 2); headings and wording take a row of their own. +function rows(fields, valueOf) { + const out = []; + let row = []; let used = 0; + const flush = () => { + if (!row.length) return; + const filler = 6 - used; + out.push({ columns: [...row.map(([f, span]) => ({ width: `${(span / 6) * 100}%`, ...cell(f, valueOf(f), span) })), + ...(filler > 0 ? [{ width: `${(filler / 6) * 100}%`, text: '' }] : [])], columnGap: 6 }); + row = []; used = 0; + }; + for (const f of fields) { + const span = f.type === 'heading' || f.type === 'paragraph' || f.type === 'signature' && f.width === 'full' ? 6 + : SPAN[f.width] || 6; + if (used + span > 6) flush(); + row.push([f, span]); used += span; + if (used >= 6) flush(); + } + flush(); + return out; +} + +/** + * @param {object} schema the form + * @param {Record} data field id (or id#n) -> value + * @param {{ dealer: string, trades: number, stamp?: string, footer?: string }} opts + */ +export function formPdf(schema, data, { dealer = '', trades = 0, stamp = null, footer = '' } = {}) { + const content = [ + { text: dealer, fontSize: 9, color: MUTED }, + { text: schema.title, fontSize: 16, bold: true, color: HEAD, margin: [0, 2, 0, 4] }, + ...(schema.description ? [{ text: schema.description, fontSize: 9, margin: [0, 0, 0, 6] }] : []), + ]; + for (const s of schema.sections) { + const copies = s.repeat === 'trade_in' ? Math.max(1, trades) : 1; + for (let n = 0; n < copies; n += 1) { + const title = s.repeat === 'trade_in' && copies > 1 ? `${s.title || 'Trade-in'} ${n + 1}` : s.title; + if (title) { + content.push({ table: { widths: ['*'], body: [[{ text: title, bold: true, fontSize: 10, color: '#ffffff', + fillColor: HEAD, margin: [4, 2, 4, 2] }]] }, layout: 'noBorders', margin: [0, 10, 0, 4] }); + } + const valueOf = (f) => data[dataKey(f.id, s.repeat === 'trade_in' ? n : null)]; + content.push(...rows(s.fields, valueOf)); + } + } + return renderPdf({ + pageSize: 'LETTER', pageMargins: [40, 40, 40, 44], + info: { title: schema.title, creator: 'AutoPulse' }, + defaultStyle: { font: 'Helvetica', fontSize: 9, color: INK }, + ...(stamp ? { watermark: { text: stamp, color: '#c8ccd3', opacity: 0.25, bold: true } } : {}), + footer: (page, pages) => ({ columns: [{ text: footer, color: MUTED, fontSize: 7 }, + { text: `Page ${page} of ${pages}`, alignment: 'right', color: MUTED, fontSize: 7 }], margin: [40, 12, 40, 0] }), + content, + }); +} diff --git a/aidmvcs-be-dev/app/lib/forms/render.js b/aidmvcs-be-dev/app/lib/forms/render.js deleted file mode 100644 index 945bff75..00000000 --- a/aidmvcs-be-dev/app/lib/forms/render.js +++ /dev/null @@ -1,133 +0,0 @@ -// Reading and filling the dealer's PDF (SOW v6 Deliverable K, gaps G-064 / G-065), with pdf-lib. -// inspectPdf - is it fillable, its form fields, its page sizes -// fillPdf - the mapped values written in: into the PDF's own fields (fillable), or as text at the placed -// positions (flat PDF); trade-ins beyond the form's trade sections go on an added "Trade-in schedule" -// page. The result is flattened, so the printed values can't be edited afterwards. -// layoutPdf - the blank form with each mapped box / field labelled and an optional grid, to check the mapping. -import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; - -const MAX_BYTES = 15 * 1024 * 1024; - -export async function inspectPdf(buffer) { - if (!buffer?.length) throw Object.assign(new Error('The file is empty'), { status: 400 }); - if (buffer.length > MAX_BYTES) throw Object.assign(new Error('The PDF is larger than 15 MB'), { status: 400 }); - if (buffer.subarray(0, 5).toString() !== '%PDF-') throw Object.assign(new Error('That file is not a PDF'), { status: 400 }); - let pdf; - try { - pdf = await PDFDocument.load(buffer, { updateMetadata: false }); - } catch (error) { - throw Object.assign(new Error(/encrypt/i.test(error?.message || '') ? 'The PDF is password-protected: upload an ' - + 'unlocked copy' : 'The PDF could not be read'), { status: 400 }); - } - const fields = pdf.getForm().getFields().map((f) => ({ name: f.getName(), type: f.constructor.name.replace(/^PDF/, '') })); - return { fillable: fields.length > 0, fields, - pages: pdf.getPages().map((p) => ({ width: Math.round(p.getWidth()), height: Math.round(p.getHeight()) })) }; -} - -// The highest trade-in section the mapping uses (trade2.* -> 2). -export function mappedTradeSlots(mapping) { - return Math.max(0, ...mapping.map((m) => Number((/^trade(\d+)\./.exec(m.source) || [])[1] || 0))); -} - -function fitText(font, value, size, maxWidth) { - let s = value; - while (s.length > 1 && maxWidth && font.widthOfTextAtSize(s, size) > maxWidth) s = s.slice(0, -1); - return s; -} - -function addTradeSchedule(pdf, font, bold, trades, { first, title }) { - let page = pdf.addPage([612, 792]); - let y = 740; - page.drawText(title, { x: 50, y, size: 14, font: bold }); - y -= 28; - trades.forEach((t, i) => { - if (y < 160) { page = pdf.addPage([612, 792]); y = 740; } - page.drawText(`Trade-in ${first + i}`, { x: 50, y, size: 11, font: bold }); - y -= 16; - const rows = [['Vehicle', t.description], ['VIN', t.vin], ['Mileage', t.miles], ['Color', t.color], - ['Trade offer', t.offer], ['ACV', t.acv], ['Payoff', t.payoff], ['Lienholder', t.lienholder]].filter(([, v]) => v); - for (const [label, value] of rows) { - page.drawText(`${label}:`, { x: 60, y, size: 10, font: bold }); - page.drawText(String(value), { x: 150, y, size: 10, font }); - y -= 14; - } - y -= 10; - }); -} - -/** - * @param {Buffer} buffer the template version's PDF - * @param {object[]} mapping [{ field } | { page, x, y, size }, source] - * @param {Record} values source -> text ('' = missing, left blank) - * @param {{ allTrades?: object[] }} opts every trade-in picked (those past the mapped sections go on a schedule) - */ -export async function fillPdf(buffer, mapping, values, { allTrades = [] } = {}) { - const pdf = await PDFDocument.load(buffer, { updateMetadata: false }); - const font = await pdf.embedFont(StandardFonts.Helvetica); - const bold = await pdf.embedFont(StandardFonts.HelveticaBold); - const form = pdf.getForm(); - const pages = pdf.getPages(); - for (const m of mapping) { - const value = values[m.source] ?? ''; - if (!value) continue; - if (m.field) { - let field; - try { field = form.getField(m.field); } catch { continue; } // a field the new version no longer has - const kind = field.constructor.name; - if (kind === 'PDFTextField') field.setText(String(value)); - else if (kind === 'PDFCheckBox') { if (/^(yes|true|x|1)$/i.test(String(value))) field.check(); } - else if (kind === 'PDFDropdown' || kind === 'PDFOptionList') { - if (field.getOptions().includes(String(value))) field.select(String(value)); - } else if (kind === 'PDFRadioGroup') { - if (field.getOptions().includes(String(value))) field.select(String(value)); - } - } else if (m.page && pages[m.page - 1]) { - const page = pages[m.page - 1]; - const size = Number(m.size) || 10; - page.drawText(fitText(font, String(value), size, page.getWidth() - Number(m.x) - 10), - { x: Number(m.x), y: Number(m.y), size, font, color: rgb(0, 0, 0) }); - } - } - try { form.updateFieldAppearances(font); form.flatten(); } catch { /* a form with odd widgets: values stay as set */ } - const slots = mappedTradeSlots(mapping); - if (allTrades.length > slots) { - addTradeSchedule(pdf, font, bold, allTrades.slice(slots), { first: slots + 1, - title: slots ? 'Trade-in schedule (additional trade-ins)' : 'Trade-in schedule' }); - } - return Buffer.from(await pdf.save()); -} - -/** The blank form with each mapped place labelled ("Customer: Full name") and, if asked, a 50-point grid. */ -export async function layoutPdf(buffer, mapping, labelOf, { grid = false } = {}) { - const pdf = await PDFDocument.load(buffer, { updateMetadata: false }); - const font = await pdf.embedFont(StandardFonts.Helvetica); - const form = pdf.getForm(); - const pages = pdf.getPages(); - if (grid) { - for (const page of pages) { - const w = page.getWidth(); const h = page.getHeight(); - for (let x = 0; x <= w; x += 50) { - page.drawLine({ start: { x, y: 0 }, end: { x, y: h }, thickness: 0.3, color: rgb(0.6, 0.75, 0.95) }); - page.drawText(String(x), { x: x + 1, y: 2, size: 6, font, color: rgb(0.2, 0.4, 0.8) }); - } - for (let y = 0; y <= h; y += 50) { - page.drawLine({ start: { x: 0, y }, end: { x: w, y }, thickness: 0.3, color: rgb(0.6, 0.75, 0.95) }); - page.drawText(String(y), { x: 2, y: y + 1, size: 6, font, color: rgb(0.2, 0.4, 0.8) }); - } - } - } - for (const m of mapping) { - const label = `[${labelOf(m.source)}]`; - if (m.field) { - try { - const field = form.getField(m.field); - if (field.constructor.name === 'PDFTextField') field.setText(label); - } catch { /* not on this version */ } - } else if (m.page && pages[m.page - 1]) { - pages[m.page - 1].drawText(label, { x: Number(m.x), y: Number(m.y), size: Number(m.size) || 10, font, - color: rgb(0.8, 0.1, 0.1) }); - } - } - try { form.updateFieldAppearances(font); } catch { /* preview only */ } - return Buffer.from(await pdf.save()); -} diff --git a/aidmvcs-be-dev/app/lib/forms/schema.js b/aidmvcs-be-dev/app/lib/forms/schema.js new file mode 100644 index 00000000..e6576da5 --- /dev/null +++ b/aidmvcs-be-dev/app/lib/forms/schema.js @@ -0,0 +1,75 @@ +// A dealership form as an on-screen form (SOW v6 Deliverable K; the user, 13 Nov 2026: "the actual form ... is the +// component form", the PDF is only its preview / download). Built from the dealer's PDF (./extract.js), then edited in +// the Forms Library builder like any form builder: title, sections, fields, their types, required, options, width, +// and which CRM value fills a field (./sources.js). Shared by the server and the screens - no server imports here. +// +// schema = { title, description, sections: [{ id, title, repeat: null | 'trade_in', fields: [field] }] } +// field = { id, type, label, required, width: 'full' | 'half' | 'third', options: [..], source: null | key, +// help, text (paragraph / heading wording) } + +export const FIELD_TYPES = Object.freeze({ + text: 'Short text', textarea: 'Long text', number: 'Number', money: 'Money ($)', date: 'Date', email: 'Email', + phone: 'Phone', checkbox: 'Checkbox', select: 'Dropdown', radio: 'Choice (one of)', signature: 'Signature line', + heading: 'Heading', paragraph: 'Text block (wording)', +}); +export const INPUT_TYPES = Object.freeze(['text', 'textarea', 'number', 'money', 'date', 'email', 'phone', 'checkbox', + 'select', 'radio', 'signature']); +export const WIDTHS = Object.freeze({ full: 'Full width', half: 'Half', third: 'Third' }); +export const REPEATS = Object.freeze({ trade_in: 'Once for each trade-in picked' }); + +const LIMITS = { sections: 60, fields: 400, label: 200, text: 4000, options: 50 }; +const clean = (v, max) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, max); +let seq = 0; +export const newId = (p = 'f') => `${p}_${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`; + +/** The schema as sent, cleaned; throws an Error with a plain message when it can't be used. */ +export function normalizeSchema(input, { isSource = () => true } = {}) { + if (!input || typeof input !== 'object') throw new Error('The form is empty'); + const title = clean(input.title, 200); + if (!title) throw new Error('Give the form a title'); + const sections = Array.isArray(input.sections) ? input.sections : []; + if (!sections.length) throw new Error('The form needs at least one section'); + if (sections.length > LIMITS.sections) throw new Error(`At most ${LIMITS.sections} sections`); + const ids = new Set(); + let count = 0; + const out = sections.map((s, si) => { + const fields = (Array.isArray(s?.fields) ? s.fields : []).map((f, fi) => { + const where = `Section ${si + 1}, field ${fi + 1}`; + if (!FIELD_TYPES[f?.type]) throw new Error(`${where}: unknown field type`); + let id = clean(f.id, 60) || newId(); + if (ids.has(id)) id = newId(); + ids.add(id); + const field = { id, type: f.type, label: clean(f.label, LIMITS.label), required: Boolean(f.required), + width: WIDTHS[f.width] ? f.width : 'full', options: [], source: null, help: clean(f.help, 300) || null, + text: null }; + if (f.type === 'paragraph' || f.type === 'heading') { + field.text = String(f.text ?? f.label ?? '').trim().slice(0, LIMITS.text); + field.required = false; + if (!field.text) throw new Error(`${where}: the text block is empty`); + } else if (!field.label) throw new Error(`${where}: give the field a label`); + if (f.type === 'select' || f.type === 'radio') { + field.options = [...new Set((f.options || []).map((o) => clean(o, 120)).filter(Boolean))].slice(0, LIMITS.options); + if (!field.options.length) throw new Error(`${where} ("${field.label}"): add the choices`); + } + if (f.source && INPUT_TYPES.includes(f.type)) { + if (!isSource(f.source)) throw new Error(`${where} ("${field.label}"): unknown CRM value`); + field.source = f.source; + } + return field; + }); + count += fields.length; + return { id: clean(s?.id, 60) || newId('s'), title: clean(s?.title, 200), repeat: REPEATS[s?.repeat] ? s.repeat : null, + fields }; + }); + if (count > LIMITS.fields) throw new Error(`At most ${LIMITS.fields} fields`); + return { title, description: String(input.description ?? '').trim().slice(0, 2000) || null, sections: out }; +} + +// The fields a person fills (not headings / text blocks), with the section they're in. +export function inputFields(schema) { + return (schema?.sections || []).flatMap((s) => s.fields.filter((f) => INPUT_TYPES.includes(f.type)) + .map((f) => ({ ...f, section: s }))); +} + +// Data keys: a field's id, or `${id}#${n}` in a section repeated per trade-in (n = 0, 1, ...). +export const dataKey = (fieldId, n = null) => (n == null ? fieldId : `${fieldId}#${n}`); diff --git a/aidmvcs-be-dev/app/lib/forms/sources.js b/aidmvcs-be-dev/app/lib/forms/sources.js index 04499ed0..ce70c77e 100644 --- a/aidmvcs-be-dev/app/lib/forms/sources.js +++ b/aidmvcs-be-dev/app/lib/forms/sources.js @@ -1,5 +1,6 @@ -// What a form can be filled with (SOW v6 Deliverable K, gap G-065): the customer, the opportunity picked (lead), its -// vehicle, up to MAX_TRADES trade-ins (more go on an added "Trade-in schedule" page), dates, staff and the dealership. +// What a form field can be filled with (SOW v6 Deliverable K, gap G-065): the customer, the opportunity picked (lead), +// its vehicle, the trade-ins picked (a section set to repeat per trade-in gets one copy each; outside one, the first +// trade-in), dates, staff and the dealership. Staff can change or complete any value on the form before saving. // Only values the CRM already has: an empty value stays empty and is flagged missing - nothing is ever made up. // Sensitive values (trade payoff, lienholder) only for staff with "Manage Leads", as on the Trade In tab; the CRM // collects no SSN, date of birth, credit or bank data, so no form can be filled with them. @@ -11,7 +12,6 @@ import TradeIn from '../../models/TradeIn.js'; import User from '../../models/User.js'; import DeskLogEntry from '../../models/DeskLogEntry.js'; -export const MAX_TRADES = 3; const TRADE_FIELDS = [['year', 'Year'], ['make', 'Make'], ['model', 'Model'], ['trim', 'Trim'], ['vin', 'VIN'], ['miles', 'Mileage'], ['color', 'Color'], ['description', 'Year make model'], ['offer', 'Trade offer'], ['acv', 'ACV'], ['payoff', 'Payoff', true], ['lienholder', 'Lienholder', true]]; @@ -25,8 +25,7 @@ export const SOURCES = Object.freeze([ ['vehicle.year', 'Vehicle', 'Year'], ['vehicle.make', 'Vehicle', 'Make'], ['vehicle.model', 'Vehicle', 'Model'], ['vehicle.trim', 'Vehicle', 'Trim'], ['vehicle.vin', 'Vehicle', 'VIN'], ['vehicle.stock_number', 'Vehicle', 'Stock #'], ['vehicle.description', 'Vehicle', 'Year make model trim'], - ...Array.from({ length: MAX_TRADES }, (_, i) => TRADE_FIELDS.map(([k, label, sensitive]) => [ - `trade${i + 1}.${k}`, `Trade-in ${i + 1}`, label, sensitive])).flat(), + ...TRADE_FIELDS.map(([k, label, sensitive]) => [`trade.${k}`, 'Trade-in', label, sensitive]), ['date.today', 'Dates', "Today's date"], ['date.appointment', 'Dates', 'Appointment date'], ['date.visit', 'Dates', 'Showroom visit date'], ['date.lead', 'Dates', 'Lead received'], ['staff.salesperson', 'Staff', 'Salesperson'], ['staff.sales_manager', 'Staff', 'Sales manager'], @@ -59,7 +58,7 @@ function splitName(full) { /** * Every source's value for one customer + opportunity + trade-ins, as it would print. - * @returns {Promise<{values: Record, trades: object[], extraTrades: object[], lead: object|null}>} + * @returns {Promise<{values: Record, trades: Record[], lead: object|null}>} */ export async function collectValues({ dealerId, customerId, leadId, tradeInIds = [], user, canSeeFinance, tz }) { const customer = await Customer.findOne({ _id: customerId, dealer_id: dealerId }).lean(); @@ -114,9 +113,8 @@ export async function collectValues({ dealerId, customerId, leadId, tradeInIds = offer: money(t.trade_offer_amount), acv: money(t.trade_acv_amount), payoff: canSeeFinance ? money(t.payoff_amount) : '', lienholder: canSeeFinance ? text(t.lienholder) : '', }); - trades.slice(0, MAX_TRADES).forEach((t, i) => { - for (const [k, val] of Object.entries(tradeValues(t))) values[`trade${i + 1}.${k}`] = val; - }); - for (let i = trades.length; i < MAX_TRADES; i += 1) for (const [k] of TRADE_FIELDS) values[`trade${i + 1}.${k}`] = ''; - return { values, lead, extraTrades: trades.slice(MAX_TRADES).map(tradeValues), allTrades: trades.map(tradeValues) }; + // One map per trade-in picked: 'trade.year' -> value. Outside a repeated section a trade field takes the first one. + const tradeMaps = trades.map((t) => Object.fromEntries(Object.entries(tradeValues(t)).map(([k, val]) => [`trade.${k}`, val]))); + for (const [k] of TRADE_FIELDS) values[`trade.${k}`] = tradeMaps[0]?.[`trade.${k}`] || ''; + return { values, lead, trades: tradeMaps }; } diff --git a/aidmvcs-be-dev/app/models/FormDocument.js b/aidmvcs-be-dev/app/models/FormDocument.js index d97242ad..1f73510e 100644 --- a/aidmvcs-be-dev/app/models/FormDocument.js +++ b/aidmvcs-be-dev/app/models/FormDocument.js @@ -1,20 +1,22 @@ import mongoose from 'mongoose'; -// A form filled in for one customer and one opportunity (SOW v6 Deliverable K, gap G-066): an immutable snapshot - the -// PDF as generated, the template version it came from, the values that went in, what was missing, who made it and -// when. A later change to the customer never rewrites it; every download / print is logged. +// A form filled for one customer and one opportunity (SOW v6 Deliverable K, gap G-066): the form exactly as it was +// (schema of the template version) with what was entered (`data`). A draft can still be changed; a completed one never +// changes again - later changes to the customer or to the form don't touch it. The PDF is made from it on request. const formDocumentSchema = new mongoose.Schema({ dealer_id: { type: String, required: true }, template_id: { type: mongoose.Schema.Types.ObjectId, ref: 'FormTemplate', required: true }, template_name: { type: String, default: null }, template_version: { type: Number, required: true }, + schema: { type: Object, required: true }, + data: { type: Object, default: {} }, // field id (or id#n in a section repeated per trade-in) -> value + prefilled: { type: [String], default: [] }, // data keys that came from the CRM customer_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Customer', required: true }, lead_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Lead', default: null }, trade_in_ids: { type: [mongoose.Schema.Types.ObjectId], default: [] }, - values: { type: Object, default: {} }, // source key -> the value printed (sensitive values are not kept here) - missing: { type: [String], default: [] }, - file_id: { type: mongoose.Schema.Types.ObjectId, default: null }, // null once removed at the end of retention - size: { type: Number, default: 0 }, + status: { type: String, enum: ['draft', 'completed'], default: 'draft' }, + completed_at: { type: Date, default: null }, + completed_by_name: { type: String, default: null }, created_by: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, created_by_name: { type: String, default: null }, retain_until: { type: Date, default: null }, @@ -24,7 +26,7 @@ const formDocumentSchema = new mongoose.Schema({ { _id: false })], default: [], }, -}, { timestamps: { createdAt: 'created_at', updatedAt: false } }); +}, { timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }); formDocumentSchema.index({ dealer_id: 1, customer_id: 1, created_at: -1 }); diff --git a/aidmvcs-be-dev/app/models/FormTemplate.js b/aidmvcs-be-dev/app/models/FormTemplate.js index 3c3a7874..de5a3444 100644 --- a/aidmvcs-be-dev/app/models/FormTemplate.js +++ b/aidmvcs-be-dev/app/models/FormTemplate.js @@ -1,38 +1,24 @@ import mongoose from 'mongoose'; -// A dealership's form in its Forms & Documents Library (SOW v6 Deliverable K, gaps G-064 - G-067). The dealer uploads -// its own approved PDF (fillable, or flat with text placed at set positions); AutoPulse only fills it in. Every upload -// is a new version (the older ones are kept, so a stored document always knows the exact version it was made from). -// The field mapping belongs to a version: field (PDF form field name, or a placed box on a flat PDF) -> data source. -const mappingSchema = new mongoose.Schema({ - // Fillable PDF: the PDF field's name. Flat PDF: a box placed on a page (points from the bottom-left corner). - field: { type: String, default: null }, - page: { type: Number, default: null }, x: { type: Number, default: null }, y: { type: Number, default: null }, - size: { type: Number, default: 10 }, - label: { type: String, default: null }, // what the box is, for a flat PDF ("Buyer name") - source: { type: String, required: true }, // app/lib/forms/sources.js key - required: { type: Boolean, default: false }, -}, { _id: false }); - +// A dealership form in its Forms Library (SOW v6 Deliverable K, gaps G-064 - G-067; the user, 13 Nov 2026). The form +// is an on-screen form (`schema`, app/lib/forms/schema.js) built from the dealer's PDF and edited in the builder; the +// PDF it was read from is kept for reference only. Every saved change to a form already used for a customer is a new +// version, so a filled copy always keeps the exact form it was made with. const versionSchema = new mongoose.Schema({ version: { type: Number, required: true }, - file_id: { type: mongoose.Schema.Types.ObjectId, required: true }, // GridFS (app/lib/forms/storage.js) - filename: { type: String, default: null }, - size: { type: Number, default: 0 }, - fillable: { type: Boolean, default: false }, - fields: { type: [new mongoose.Schema({ name: String, type: String }, { _id: false })], default: [] }, - pages: { type: [new mongoose.Schema({ width: Number, height: Number }, { _id: false })], default: [] }, - mapping: { type: [mappingSchema], default: [] }, - uploaded_by: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, - uploaded_by_name: { type: String, default: null }, - uploaded_at: { type: Date, default: Date.now }, + schema: { type: Object, required: true }, + source_file_id: { type: mongoose.Schema.Types.ObjectId, default: null }, // the uploaded PDF (GridFS), if any + source_filename: { type: String, default: null }, + created_by: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, + created_by_name: { type: String, default: null }, + created_at: { type: Date, default: Date.now }, }, { _id: false }); const historySchema = new mongoose.Schema({ at: { type: Date, default: Date.now }, by: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, by_name: { type: String, default: null }, - action: { type: String, required: true }, // uploaded | new_version | settings | mapping | activated | deactivated + action: { type: String, required: true }, // created | edited | new_version | settings | activated | deactivated detail: { type: String, default: null }, }, { _id: false }); @@ -43,11 +29,11 @@ const formTemplateSchema = new mongoose.Schema({ dealer_id: { type: String, required: true, index: true }, name: { type: String, required: true, trim: true }, category: { type: String, enum: FORM_CATEGORIES, default: 'Other' }, - active: { type: Boolean, default: true }, + active: { type: Boolean, default: false }, // switched on by the dealer once the form is checked departments: { type: [String], default: [] }, // sales | service; empty = both role_ids: { type: [mongoose.Schema.Types.ObjectId], default: [] }, // empty = every staff role statuses: { type: [String], default: [] }, // lead statuses that make it available; empty = any - retention_days: { type: Number, default: null }, // generated copies kept this long; null = kept + retention_days: { type: Number, default: null }, // filled copies kept this long; null = kept current_version: { type: Number, default: 1 }, versions: { type: [versionSchema], default: [] }, history: { type: [historySchema], default: [] }, diff --git a/aidmvcs-be-dev/package-lock.json b/aidmvcs-be-dev/package-lock.json index e5c962b0..ace8fa79 100644 --- a/aidmvcs-be-dev/package-lock.json +++ b/aidmvcs-be-dev/package-lock.json @@ -69,6 +69,7 @@ "swiper": "^12.1.3", "tinymce": "^8.3.2", "twilio": "^5.5.1", + "unpdf": "^0.12.1", "vvvebjs": "^2.0.9" }, "devDependencies": { @@ -1947,6 +1948,48 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/@measured/puck": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@measured/puck/-/puck-0.20.2.tgz", @@ -3891,6 +3934,13 @@ "libqp": "2.1.1" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4059,6 +4109,43 @@ "lodash.throttle": "^4.0.1" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4381,7 +4468,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/base64-js": { @@ -4490,7 +4577,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4702,6 +4789,22 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4757,6 +4860,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/classlist-polyfill": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz", @@ -4832,6 +4945,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4858,9 +4981,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -5206,6 +5336,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/deep-diff": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", @@ -5301,6 +5444,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -6777,6 +6927,39 @@ "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==" }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6867,6 +7050,80 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -7178,6 +7435,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7440,6 +7704,18 @@ ], "license": "MIT" }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7723,7 +7999,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8497,6 +8773,32 @@ "node": ">=6.0.0" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8597,11 +8899,24 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -8630,6 +8945,46 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -8831,6 +9186,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.30.0.tgz", + "integrity": "sha512-P5mokDkSvCGBz6mZDD/2l+w9QoICRXdCyZ9ONGmTePr40oihJg3Kpn0YU3twKd6PJnwSlLBWNQeOT6yWEV2c4A==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", @@ -9069,6 +9431,22 @@ "node": ">=6.0.0" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -9079,6 +9457,20 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/oauth": { "version": "0.9.15", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", @@ -9326,34 +9718,12 @@ } } }, - "node_modules/openai/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/openai/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, - "node_modules/openai/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/openai/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -9614,6 +9984,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -10697,6 +11077,45 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10924,6 +11343,13 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -11141,6 +11567,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -11711,6 +12170,35 @@ "node": ">= 6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -11833,6 +12321,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -12151,6 +12645,15 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, + "node_modules/unpdf": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-0.12.1.tgz", + "integrity": "sha512-ktP8+TTLDBrlu/j8rQVNbHoMMpFXzkVAkb1rt/JdshFC3jOHdZjuGCNl/voPL0kraUrUOH7ZC88kVxMvlvDBzA==", + "license": "MIT", + "optionalDependencies": { + "canvas": "^2.11.2" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -12582,6 +13085,22 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/whatwg-url/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12686,6 +13205,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/with-callback": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz", diff --git a/aidmvcs-be-dev/package.json b/aidmvcs-be-dev/package.json index 75786a71..0ceedfc3 100644 --- a/aidmvcs-be-dev/package.json +++ b/aidmvcs-be-dev/package.json @@ -88,6 +88,7 @@ "swiper": "^12.1.3", "tinymce": "^8.3.2", "twilio": "^5.5.1", + "unpdf": "^0.12.1", "vvvebjs": "^2.0.9" }, "overrides": {