Generate adoption reports as PDFs
This commit is contained in:
@@ -64,6 +64,7 @@ import {
|
||||
updateVetVisitForBird,
|
||||
} from './repositories/birdRepository.js';
|
||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||
import { renderAdoptionReportPdf } from './reports/adoptionReport.js';
|
||||
import {
|
||||
createAuditLogEntry,
|
||||
createFlockNote,
|
||||
@@ -149,6 +150,7 @@ const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
||||
const adoptionReportWeightHistoryDays = 425;
|
||||
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||
|
||||
if (trustProxy) {
|
||||
@@ -781,6 +783,7 @@ app.disable('x-powered-by');
|
||||
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||
app.use(
|
||||
cors({
|
||||
exposedHeaders: ['X-FlockPal-Transfer-Code'],
|
||||
origin(origin, callback) {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
@@ -3362,6 +3365,92 @@ app.post(
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/api/birds/:birdId/reports/adoption',
|
||||
requireAuth,
|
||||
requireWriteAccess,
|
||||
requireSessionAuth,
|
||||
requireWorkspaceRole(['owner', 'assistant']),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||
|
||||
if (!sourceBird) {
|
||||
res.status(404).json({ error: 'Bird not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ensureBirdWritable(sourceBird, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let transferCode = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
transferCode = await createBirdTransferCode({
|
||||
code: createBirdTransferCodeValue(),
|
||||
birdId: sourceBird.id,
|
||||
sourceWorkspaceId: req.auth!.workspace.id,
|
||||
requestedByUserId: req.auth!.user.id,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!transferCode) {
|
||||
throw new Error('Unable to create bird transfer code.');
|
||||
}
|
||||
|
||||
const [weights, vetVisits, notes] = await Promise.all([
|
||||
listWeightsForBird(sourceBird.id, req.auth!.workspace.id, adoptionReportWeightHistoryDays),
|
||||
listVetVisitsForBird(sourceBird.id, req.auth!.workspace.id),
|
||||
listFlockNotes(req.auth!.workspace.id),
|
||||
]);
|
||||
const birdNotes = notes.filter((note) => note.bird_id === sourceBird.id);
|
||||
const pdf = await renderAdoptionReportPdf({
|
||||
bird: sourceBird,
|
||||
weights,
|
||||
vetVisits,
|
||||
notes: birdNotes,
|
||||
workspace: req.auth!.workspace,
|
||||
transferCode: transferCode.code,
|
||||
printFriendly: req.query.printFriendly === 'true',
|
||||
assets: {
|
||||
logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'),
|
||||
wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'),
|
||||
defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'),
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, {
|
||||
transferCodeId: transferCode.id,
|
||||
printFriendly: req.query.printFriendly === 'true',
|
||||
});
|
||||
|
||||
const safeName = sourceBird.name
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/gi, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase() || 'bird';
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="flockpal-adoption-report-${safeName}.pdf"`);
|
||||
res.setHeader('Content-Length', pdf.length.toString());
|
||||
res.setHeader('X-FlockPal-Transfer-Code', transferCode.code);
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const parsed = birdSchema.safeParse(req.body);
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import fs from 'fs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow, WorkspaceRow } from '../types.js';
|
||||
|
||||
type AdoptionReportInput = {
|
||||
bird: BirdRow;
|
||||
weights: WeightRow[];
|
||||
vetVisits: VetVisitRow[];
|
||||
notes: FlockNoteRow[];
|
||||
workspace: WorkspaceRow;
|
||||
transferCode: string;
|
||||
assets: {
|
||||
logoPath: string;
|
||||
wordmarkPath: string;
|
||||
defaultBirdPhotoPath: string;
|
||||
};
|
||||
printFriendly?: boolean;
|
||||
};
|
||||
|
||||
const page = { width: 612, height: 792, margin: 42 };
|
||||
|
||||
const colors = {
|
||||
ink: '#1f2a2a',
|
||||
muted: '#5d5f59',
|
||||
red: '#cb3a35',
|
||||
green: '#238a5a',
|
||||
blue: '#2769b3',
|
||||
border: '#cfe0d5',
|
||||
panel: '#fbf7ee',
|
||||
paper: '#fffdf9',
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (!value) {
|
||||
return 'Not recorded';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(
|
||||
new Date(`${value.slice(0, 10)}T00:00:00Z`),
|
||||
);
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string | null) => {
|
||||
if (!value) {
|
||||
return 'Not recorded';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value));
|
||||
};
|
||||
|
||||
const formatWeight = (value: string | number | null) => {
|
||||
const numericValue = value === null ? null : Number(value);
|
||||
return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending';
|
||||
};
|
||||
|
||||
const genderLabel = (value: string) => {
|
||||
if (value === 'female') {
|
||||
return 'Female';
|
||||
}
|
||||
if (value === 'male') {
|
||||
return 'Male';
|
||||
}
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const parseList = (value: string | null) =>
|
||||
(value ?? '')
|
||||
.split(/\r?\n|,/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const dataUrlToBuffer = (value: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/);
|
||||
return match ? Buffer.from(match[1], 'base64') : null;
|
||||
};
|
||||
|
||||
const collectPdf = (doc: PDFKit.PDFDocument) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
});
|
||||
|
||||
const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, width: number, options: PDFKit.Mixins.TextOptions = {}) => {
|
||||
doc.text(text, x, y, { width, lineGap: 1.5, ...options });
|
||||
return doc.y;
|
||||
};
|
||||
|
||||
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => {
|
||||
doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border);
|
||||
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
|
||||
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true });
|
||||
};
|
||||
|
||||
const drawSectionTitle = (doc: PDFKit.PDFDocument, title: string, y: number) => {
|
||||
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(14).text(title, page.margin, y);
|
||||
doc.moveTo(page.margin, y + 19).lineTo(page.width - page.margin, y + 19).strokeColor(colors.border).lineWidth(1).stroke();
|
||||
return y + 27;
|
||||
};
|
||||
|
||||
const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], birdColor: string, x: number, y: number, width: number, height: number) => {
|
||||
const plottedWeights = weights
|
||||
.slice()
|
||||
.sort((left, right) => left.recorded_on.localeCompare(right.recorded_on))
|
||||
.map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) }))
|
||||
.filter((entry) => Number.isFinite(entry.numericWeight));
|
||||
|
||||
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#ffffff', colors.border);
|
||||
|
||||
if (plottedWeights.length < 2) {
|
||||
doc.fillColor(colors.muted).fontSize(10).text('Add more weight records to show a trend graph.', x + 14, y + height / 2 - 6, {
|
||||
width: width - 28,
|
||||
align: 'center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const minWeight = Math.min(...plottedWeights.map((entry) => entry.numericWeight));
|
||||
const maxWeight = Math.max(...plottedWeights.map((entry) => entry.numericWeight));
|
||||
const weightRange = Math.max(1, maxWeight - minWeight);
|
||||
const padding = 24;
|
||||
const plotWidth = width - padding * 2;
|
||||
const plotHeight = height - padding * 2;
|
||||
const chartColor = /^#[0-9a-fA-F]{6}$/.test(birdColor) ? birdColor : colors.green;
|
||||
|
||||
doc.strokeColor('#d9e6dc').lineWidth(0.8);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const gridY = y + padding + (plotHeight / 3) * index;
|
||||
doc.moveTo(x + padding, gridY).lineTo(x + width - padding, gridY).stroke();
|
||||
}
|
||||
|
||||
plottedWeights.forEach((entry, index) => {
|
||||
const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index;
|
||||
const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight;
|
||||
if (index === 0) {
|
||||
doc.moveTo(pointX, pointY);
|
||||
} else {
|
||||
doc.lineTo(pointX, pointY);
|
||||
}
|
||||
});
|
||||
doc.strokeColor(chartColor).lineWidth(2).stroke();
|
||||
|
||||
plottedWeights.forEach((entry, index) => {
|
||||
const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index;
|
||||
const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight;
|
||||
doc.circle(pointX, pointY, 2.6).fillAndStroke(chartColor, '#ffffff');
|
||||
});
|
||||
};
|
||||
|
||||
const drawTable = (doc: PDFKit.PDFDocument, headers: string[], rows: string[][], x: number, y: number, widths: number[], rowHeight = 28) => {
|
||||
doc.font('Helvetica-Bold').fontSize(8).fillColor(colors.muted);
|
||||
headers.forEach((header, index) => {
|
||||
doc.text(header.toUpperCase(), x + widths.slice(0, index).reduce((sum, value) => sum + value, 0), y, { width: widths[index] - 8 });
|
||||
});
|
||||
y += 15;
|
||||
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
|
||||
|
||||
doc.font('Helvetica').fontSize(8.5).fillColor(colors.ink);
|
||||
rows.forEach((row) => {
|
||||
if (y + rowHeight > page.height - page.margin) {
|
||||
doc.addPage();
|
||||
y = page.margin;
|
||||
}
|
||||
row.forEach((value, index) => {
|
||||
doc.text(value, x + widths.slice(0, index).reduce((sum, columnWidth) => sum + columnWidth, 0), y, {
|
||||
width: widths[index] - 8,
|
||||
height: rowHeight - 6,
|
||||
ellipsis: true,
|
||||
});
|
||||
});
|
||||
y += rowHeight;
|
||||
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
|
||||
});
|
||||
|
||||
return y + 6;
|
||||
};
|
||||
|
||||
export const renderAdoptionReportPdf = async ({
|
||||
bird,
|
||||
weights,
|
||||
vetVisits,
|
||||
notes,
|
||||
workspace,
|
||||
transferCode,
|
||||
assets,
|
||||
printFriendly = false,
|
||||
}: AdoptionReportInput) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'LETTER',
|
||||
margin: page.margin,
|
||||
info: { Title: `FlockPal Adoption Report - ${bird.name}`, Author: 'FlockPal', Subject: `Adoption report for ${bird.name}` },
|
||||
});
|
||||
const output = collectPdf(doc);
|
||||
|
||||
if (!printFriendly) {
|
||||
doc.rect(0, 0, page.width, page.height).fill(colors.paper);
|
||||
}
|
||||
|
||||
const logoPath = fs.existsSync(assets.logoPath) ? assets.logoPath : null;
|
||||
const wordmarkPath = fs.existsSync(assets.wordmarkPath) ? assets.wordmarkPath : logoPath;
|
||||
const defaultPhotoPath = fs.existsSync(assets.defaultBirdPhotoPath) ? assets.defaultBirdPhotoPath : null;
|
||||
const photoBuffer = dataUrlToBuffer(bird.photo_data_url);
|
||||
const contentWidth = page.width - page.margin * 2;
|
||||
const headerY = page.margin;
|
||||
const headerHeight = 120;
|
||||
|
||||
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
|
||||
if (logoPath) {
|
||||
doc.image(logoPath, page.margin + 10, headerY + 18, { fit: [92, 84], align: 'center', valign: 'center' });
|
||||
}
|
||||
|
||||
const photoX = page.margin + 235;
|
||||
const photoY = headerY + 13;
|
||||
if (photoBuffer) {
|
||||
doc.image(photoBuffer, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
|
||||
} else if (defaultPhotoPath) {
|
||||
doc.image(defaultPhotoPath, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
|
||||
}
|
||||
doc.roundedRect(photoX, photoY, 58, 58, 10).strokeColor('#ffffff').lineWidth(2).stroke();
|
||||
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(22).text(bird.name, page.margin + 140, headerY + 75, { width: 250, align: 'center' });
|
||||
doc.fillColor(colors.muted).font('Helvetica').fontSize(9).text('Adoption Report', page.margin + 140, headerY + 98, { width: 250, align: 'center' });
|
||||
|
||||
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
|
||||
const qrBuffer = dataUrlToBuffer(qrDataUrl);
|
||||
const qrX = page.width - page.margin - 110;
|
||||
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 6, { width: 96, align: 'center' });
|
||||
if (wordmarkPath) {
|
||||
doc.image(wordmarkPath, qrX - 7, headerY + 12, { fit: [110, 42], align: 'center', valign: 'center' });
|
||||
}
|
||||
if (qrBuffer) {
|
||||
doc.image(qrBuffer, qrX + 18, headerY + 50, { width: 60 });
|
||||
}
|
||||
doc.fillColor(colors.ink).font('Helvetica').fontSize(7).text(transferCode, qrX - 8, headerY + 111, { width: 112, align: 'center' });
|
||||
|
||||
let y = headerY + headerHeight + 24;
|
||||
y = drawSectionTitle(doc, 'Flock Member Info', y);
|
||||
const factGap = 8;
|
||||
const factWidth = (contentWidth - factGap) / 2;
|
||||
const facts = [
|
||||
['Name', bird.name],
|
||||
['Species', bird.species],
|
||||
['Band/tag ID', bird.tag_id || 'Not recorded'],
|
||||
['Sex', genderLabel(bird.gender)],
|
||||
['Hatch day', formatDate(bird.date_of_birth)],
|
||||
['Favorite snack', bird.favorite_snack || 'Not recorded'],
|
||||
['Latest weight', bird.latest_weight_grams ? `${formatWeight(bird.latest_weight_grams)}${bird.latest_recorded_on ? ` on ${formatDate(bird.latest_recorded_on)}` : ''}` : 'Pending'],
|
||||
['Source flock', workspace.name],
|
||||
];
|
||||
facts.forEach(([label, value], index) => {
|
||||
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
||||
});
|
||||
y += Math.ceil(facts.length / 2) * 50 + 8;
|
||||
|
||||
const motivators = parseList(bird.motivators);
|
||||
const demotivators = parseList(bird.demotivators);
|
||||
doc.fillColor(colors.blue).font('Helvetica-Bold').fontSize(10).text('Motivators', page.margin, y);
|
||||
fitText(doc, motivators.length ? motivators.join(', ') : 'Not recorded', page.margin, y + 14, factWidth);
|
||||
doc.fillColor(colors.blue).font('Helvetica-Bold').fontSize(10).text('Demotivators', page.margin + factWidth + factGap, y);
|
||||
fitText(doc, demotivators.length ? demotivators.join(', ') : 'Not recorded', page.margin + factWidth + factGap, y + 14, factWidth);
|
||||
y += 52;
|
||||
|
||||
y = drawSectionTitle(doc, 'Weight Graph', y);
|
||||
drawSimpleWeightChart(doc, weights, bird.chart_color, page.margin, y, contentWidth, 120);
|
||||
y += 140;
|
||||
|
||||
y = drawSectionTitle(doc, 'Weight History', y);
|
||||
y = drawTable(
|
||||
doc,
|
||||
['Date', 'Weight', 'Notes'],
|
||||
weights.length ? weights.map((entry) => [formatDate(entry.recorded_on), formatWeight(entry.weight_grams), entry.notes || '']) : [['No weights recorded.', '', '']],
|
||||
page.margin,
|
||||
y,
|
||||
[95, 70, contentWidth - 165],
|
||||
24,
|
||||
);
|
||||
|
||||
if (y > 610) {
|
||||
doc.addPage();
|
||||
y = page.margin;
|
||||
}
|
||||
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
|
||||
const vetFacts = [
|
||||
['Clinic name', bird.vet_clinic_name || 'Not recorded'],
|
||||
['Clinic address', bird.vet_clinic_address || 'Not recorded'],
|
||||
['Account #', bird.vet_account_number || 'Not recorded'],
|
||||
['Dr. name', bird.vet_doctor_name || 'Not recorded'],
|
||||
];
|
||||
vetFacts.forEach(([label, value], index) => {
|
||||
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
||||
});
|
||||
y += Math.ceil(vetFacts.length / 2) * 50 + 8;
|
||||
|
||||
y = drawSectionTitle(doc, 'Vet Visit History', y);
|
||||
y = drawTable(
|
||||
doc,
|
||||
['Date', 'Clinic', 'Reason', 'Notes'],
|
||||
vetVisits.length ? vetVisits.map((visit) => [formatDate(visit.visited_on), visit.clinic_name, visit.reason, visit.notes || '']) : [['No vet visits recorded.', '', '', '']],
|
||||
page.margin,
|
||||
y,
|
||||
[70, 115, 120, contentWidth - 305],
|
||||
28,
|
||||
);
|
||||
|
||||
if (notes.length) {
|
||||
if (y > 635) {
|
||||
doc.addPage();
|
||||
y = page.margin;
|
||||
}
|
||||
y = drawSectionTitle(doc, 'Notes', y);
|
||||
notes.slice(0, 8).forEach((note) => {
|
||||
if (y > page.height - page.margin - 48) {
|
||||
doc.addPage();
|
||||
y = page.margin;
|
||||
}
|
||||
doc.fillColor(colors.muted).font('Helvetica-Bold').fontSize(8).text(formatDateTime(note.updated_at), page.margin, y);
|
||||
y = fitText(doc, note.body, page.margin, y + 12, contentWidth, { height: 44, ellipsis: true });
|
||||
y += 8;
|
||||
doc.moveTo(page.margin, y).lineTo(page.width - page.margin, y).strokeColor(colors.border).stroke();
|
||||
y += 8;
|
||||
});
|
||||
}
|
||||
|
||||
doc.end();
|
||||
return output;
|
||||
};
|
||||
Reference in New Issue
Block a user