From 603b4eee4d7cc58725ae9ad7c1d8eebda61cd9b7 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Wed, 3 Jun 2026 10:55:09 -0400 Subject: [PATCH] Render adoption reports in worker --- backend/src/app.ts | 59 +++------------ backend/src/queues/adoptionReportQueue.ts | 43 +++++++++++ backend/src/reports/adoptionReportJob.ts | 87 +++++++++++++++++++++++ backend/src/worker.ts | 29 ++++++++ docker-compose.prod.yml | 1 + docker-compose.yml | 1 + 6 files changed, 169 insertions(+), 51 deletions(-) create mode 100644 backend/src/queues/adoptionReportQueue.ts create mode 100644 backend/src/reports/adoptionReportJob.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index b55bf56..2d3d039 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,6 +13,7 @@ import Stripe from 'stripe'; import { z } from 'zod'; import { ensureSchema } from './db/schema.js'; +import { enqueueAdoptionReportJob, adoptionReportQueueEvents } from './queues/adoptionReportQueue.js'; import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js'; import { consumeMagicLinkToken, @@ -64,7 +65,6 @@ import { updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; -import { renderAdoptionReportPdf } from './reports/adoptionReport.js'; import { createAuditLogEntry, createFlockNote, @@ -150,7 +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 adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000); const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy'; if (trustProxy) { @@ -1353,37 +1353,6 @@ const deleteBirdPhotoObjectIfNeeded = async (objectKey: string | null) => { } }; -const loadBirdReportPhotoBuffer = async (bird: BirdRow) => { - if (!bird.photo_object_key) { - return null; - } - - const s3Config = getS3ImageStorageConfig(); - - if (!s3Config) { - return null; - } - - const signedUrl = getSignedS3ObjectUrl({ - config: s3Config, - objectKey: bird.photo_object_key, - expiresInSeconds: 5 * 60, - }); - const imageResponse = await fetch(signedUrl); - - if (!imageResponse.ok) { - return null; - } - - const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || ''; - - if (!/^image\/(?:png|jpe?g)$/i.test(contentType)) { - return null; - } - - return Buffer.from(await imageResponse.arrayBuffer()); -}; - const getDefaultBirdPhotoAttachment = () => { const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png'); @@ -3439,27 +3408,15 @@ app.post( throw new Error('Unable to create bird transfer code.'); } - const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([ - listWeightsForBird(sourceBird.id, req.auth!.workspace.id, adoptionReportWeightHistoryDays), - listVetVisitsForBird(sourceBird.id, req.auth!.workspace.id), - listFlockNotes(req.auth!.workspace.id), - loadBirdReportPhotoBuffer(sourceBird), - ]); - const birdNotes = notes.filter((note) => note.bird_id === sourceBird.id); - const pdf = await renderAdoptionReportPdf({ - bird: sourceBird, - weights, - vetVisits, - notes: birdNotes, + await adoptionReportQueueEvents.waitUntilReady(); + const reportJob = await enqueueAdoptionReportJob({ + birdId: sourceBird.id, + workspaceId: req.auth!.workspace.id, transferCode: transferCode.code, - birdPhotoBuffer, 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'), - }, }); + const reportResult = await reportJob.waitUntilFinished(adoptionReportQueueEvents, adoptionReportRenderTimeoutMs); + const pdf = Buffer.from(reportResult.pdfBase64, 'base64'); await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, { transferCodeId: transferCode.id, diff --git a/backend/src/queues/adoptionReportQueue.ts b/backend/src/queues/adoptionReportQueue.ts new file mode 100644 index 0000000..7a997cc --- /dev/null +++ b/backend/src/queues/adoptionReportQueue.ts @@ -0,0 +1,43 @@ +import { Queue, QueueEvents, type Job } from 'bullmq'; + +import { redisConnection } from './redisConnection.js'; + +export type AdoptionReportJobData = { + birdId: string; + workspaceId: number; + transferCode: string; + printFriendly: boolean; +}; + +export type AdoptionReportJobResult = { + pdfBase64: string; +}; + +export const adoptionReportQueueName = 'adoption-reports'; + +export const adoptionReportQueue = new Queue(adoptionReportQueueName, { + connection: redisConnection, + defaultJobOptions: { + attempts: 2, + backoff: { + type: 'exponential', + delay: 10_000, + }, + removeOnComplete: 50, + removeOnFail: 500, + }, +}); + +export const adoptionReportQueueEvents = new QueueEvents(adoptionReportQueueName, { + connection: redisConnection, +}); + +export const enqueueAdoptionReportJob = ( + data: AdoptionReportJobData, +): Promise> => adoptionReportQueue.add('render-adoption-report', data); + +export const closeAdoptionReportQueue = async () => { + await adoptionReportQueue.close(); + await adoptionReportQueueEvents.close(); +}; + diff --git a/backend/src/reports/adoptionReportJob.ts b/backend/src/reports/adoptionReportJob.ts new file mode 100644 index 0000000..b046035 --- /dev/null +++ b/backend/src/reports/adoptionReportJob.ts @@ -0,0 +1,87 @@ +import path from 'path'; + +import { + getBirdById, + listVetVisitsForBird, + listWeightsForBird, +} from '../repositories/birdRepository.js'; +import { listFlockNotes } from '../repositories/auditRepository.js'; +import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js'; +import { getSignedS3ObjectUrl } from '../storage/s3Client.js'; +import type { BirdRow } from '../types.js'; +import { renderAdoptionReportPdf } from './adoptionReport.js'; + +const adoptionReportWeightHistoryDays = 425; + +const loadBirdReportPhotoBuffer = async (bird: BirdRow) => { + if (!bird.photo_object_key) { + return null; + } + + const s3Config = getS3ImageStorageConfig(); + + if (!s3Config) { + return null; + } + + const signedUrl = getSignedS3ObjectUrl({ + config: s3Config, + objectKey: bird.photo_object_key, + expiresInSeconds: 5 * 60, + }); + const imageResponse = await fetch(signedUrl); + + if (!imageResponse.ok) { + return null; + } + + const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || ''; + + if (!/^image\/(?:png|jpe?g)$/i.test(contentType)) { + return null; + } + + return Buffer.from(await imageResponse.arrayBuffer()); +}; + +export const renderAdoptionReportForBird = async ({ + birdId, + workspaceId, + transferCode, + printFriendly, +}: { + birdId: string; + workspaceId: number; + transferCode: string; + printFriendly: boolean; +}) => { + const bird = await getBirdById(birdId, workspaceId); + + if (!bird) { + throw new Error('Bird not found.'); + } + + const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([ + listWeightsForBird(bird.id, workspaceId, adoptionReportWeightHistoryDays), + listVetVisitsForBird(bird.id, workspaceId), + listFlockNotes(workspaceId), + loadBirdReportPhotoBuffer(bird), + ]); + const birdNotes = notes.filter((note) => note.bird_id === bird.id); + + return renderAdoptionReportPdf({ + bird, + weights, + vetVisits, + notes: birdNotes, + transferCode, + birdPhotoBuffer, + printFriendly, + 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'), + }, + }); +}; + diff --git a/backend/src/worker.ts b/backend/src/worker.ts index f0f435c..ee1c87a 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -3,6 +3,12 @@ import { Worker } from 'bullmq'; import { ensureSchema } from './db/schema.js'; import { db } from './db/client.js'; import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js'; +import { + adoptionReportQueueName, + closeAdoptionReportQueue, + type AdoptionReportJobData, + type AdoptionReportJobResult, +} from './queues/adoptionReportQueue.js'; import { birdMilestoneReminderQueueName, closeBirdMilestoneReminderQueue, @@ -10,8 +16,10 @@ import { type BirdMilestoneReminderJobResult, } from './queues/birdMilestoneReminderQueue.js'; import { redisConnection } from './queues/redisConnection.js'; +import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; let birdMilestoneWorker: Worker | null = null; +let adoptionReportWorker: Worker | null = null; const startWorker = async () => { await ensureSchema(); @@ -35,6 +43,25 @@ const startWorker = async () => { console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error); }); + adoptionReportWorker = new Worker( + adoptionReportQueueName, + async (job) => { + const pdf = await renderAdoptionReportForBird(job.data); + console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`); + return { + pdfBase64: pdf.toString('base64'), + }; + }, + { + connection: redisConnection, + concurrency: 1, + }, + ); + + adoptionReportWorker.on('failed', (job, error) => { + console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error); + }); + startBirdMilestoneReminderScheduler(); console.log('FlockPal worker started.'); }; @@ -42,7 +69,9 @@ const startWorker = async () => { const shutdown = async (signal: string) => { console.log(`FlockPal worker received ${signal}; shutting down.`); await birdMilestoneWorker?.close(); + await adoptionReportWorker?.close(); await closeBirdMilestoneReminderQueue(); + await closeAdoptionReportQueue(); await db.close(); process.exit(0); }; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 78a6a71..aaaa693 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -43,6 +43,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production} REDIS_URL: ${REDIS_URL:-redis://redis:6379} + ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000} IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-} diff --git a/docker-compose.yml b/docker-compose.yml index dc25cec..344abce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password} REDIS_URL: ${REDIS_URL:-redis://redis:6379} + ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000} IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-}