Render adoption reports in worker
This commit is contained in:
+8
-51
@@ -13,6 +13,7 @@ import Stripe from 'stripe';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
|
import { enqueueAdoptionReportJob, adoptionReportQueueEvents } from './queues/adoptionReportQueue.js';
|
||||||
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||||
import {
|
import {
|
||||||
consumeMagicLinkToken,
|
consumeMagicLinkToken,
|
||||||
@@ -64,7 +65,6 @@ import {
|
|||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
import { renderAdoptionReportPdf } from './reports/adoptionReport.js';
|
|
||||||
import {
|
import {
|
||||||
createAuditLogEntry,
|
createAuditLogEntry,
|
||||||
createFlockNote,
|
createFlockNote,
|
||||||
@@ -150,7 +150,7 @@ const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
|||||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
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';
|
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||||
|
|
||||||
if (trustProxy) {
|
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 getDefaultBirdPhotoAttachment = () => {
|
||||||
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
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.');
|
throw new Error('Unable to create bird transfer code.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([
|
await adoptionReportQueueEvents.waitUntilReady();
|
||||||
listWeightsForBird(sourceBird.id, req.auth!.workspace.id, adoptionReportWeightHistoryDays),
|
const reportJob = await enqueueAdoptionReportJob({
|
||||||
listVetVisitsForBird(sourceBird.id, req.auth!.workspace.id),
|
birdId: sourceBird.id,
|
||||||
listFlockNotes(req.auth!.workspace.id),
|
workspaceId: 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,
|
|
||||||
transferCode: transferCode.code,
|
transferCode: transferCode.code,
|
||||||
birdPhotoBuffer,
|
|
||||||
printFriendly: req.query.printFriendly === 'true',
|
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, {
|
await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, {
|
||||||
transferCodeId: transferCode.id,
|
transferCodeId: transferCode.id,
|
||||||
|
|||||||
@@ -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<AdoptionReportJobData, AdoptionReportJobResult>(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<Job<AdoptionReportJobData, AdoptionReportJobResult>> => adoptionReportQueue.add('render-adoption-report', data);
|
||||||
|
|
||||||
|
export const closeAdoptionReportQueue = async () => {
|
||||||
|
await adoptionReportQueue.close();
|
||||||
|
await adoptionReportQueueEvents.close();
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -3,6 +3,12 @@ import { Worker } from 'bullmq';
|
|||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
||||||
|
import {
|
||||||
|
adoptionReportQueueName,
|
||||||
|
closeAdoptionReportQueue,
|
||||||
|
type AdoptionReportJobData,
|
||||||
|
type AdoptionReportJobResult,
|
||||||
|
} from './queues/adoptionReportQueue.js';
|
||||||
import {
|
import {
|
||||||
birdMilestoneReminderQueueName,
|
birdMilestoneReminderQueueName,
|
||||||
closeBirdMilestoneReminderQueue,
|
closeBirdMilestoneReminderQueue,
|
||||||
@@ -10,8 +16,10 @@ import {
|
|||||||
type BirdMilestoneReminderJobResult,
|
type BirdMilestoneReminderJobResult,
|
||||||
} from './queues/birdMilestoneReminderQueue.js';
|
} from './queues/birdMilestoneReminderQueue.js';
|
||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
|
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||||
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
|
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
@@ -35,6 +43,25 @@ const startWorker = async () => {
|
|||||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
||||||
|
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();
|
startBirdMilestoneReminderScheduler();
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
};
|
};
|
||||||
@@ -42,7 +69,9 @@ const startWorker = async () => {
|
|||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
|
await adoptionReportWorker?.close();
|
||||||
await closeBirdMilestoneReminderQueue();
|
await closeBirdMilestoneReminderQueue();
|
||||||
|
await closeAdoptionReportQueue();
|
||||||
await db.close();
|
await db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
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}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
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}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
|||||||
Reference in New Issue
Block a user