15 Commits

Author SHA1 Message Date
Corey Blais c9fa7e4246 Cover consumed adoption transfer codes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m45s
2026-06-03 14:12:10 -04:00
Corey Blais 0411ec5175 Keep adoption transfer codes stable
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m17s
2026-06-03 12:10:27 -04:00
Corey Blais 7b7171c109 Prevent report card text overlap
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m47s
2026-06-03 11:54:44 -04:00
Corey Blais c02bb4d6d8 Convert report photos for PDF embedding
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m37s
2026-06-03 11:20:52 -04:00
Corey Blais 603b4eee4d Render adoption reports in worker
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m43s
2026-06-03 10:55:09 -04:00
Corey Blais 52008f5b43 Fix adoption report photos and section order
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-03 10:05:48 -04:00
Corey Blais 5b57cdd6bf Fix adoption report QR and chart layout 2026-06-03 10:05:48 -04:00
Corey Blais 60eadf0847 Refine adoption report header layout 2026-06-03 10:05:48 -04:00
Corey Blais 682ccfd41f Generate adoption reports as PDFs 2026-06-03 10:05:48 -04:00
Corey Blais 59c6b19ad6 fixing analytics icon pt2
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m15s
2026-06-02 12:55:35 -04:00
Corey Blais aa1a4cf6ff fixing analytics icon 2026-06-02 12:55:35 -04:00
Corey Blais 5f0fad3cbb Updated adoption report 2026-06-02 12:55:35 -04:00
Corey Blais 545fae59b2 Added adoption report and transfer code 2026-06-02 12:55:35 -04:00
Corey Blais d748d2db21 Added vet info
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m14s
2026-06-01 15:20:47 -04:00
blaisadmin 095c91e56d Merge branch 'release/flock-member-notes-audit'
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m16s
2026-05-30 22:50:54 -04:00
19 changed files with 3099 additions and 80 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+1049 -1
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -23,7 +23,10 @@
"helmet": "8.1.0", "helmet": "8.1.0",
"morgan": "1.10.0", "morgan": "1.10.0",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"pdfkit": "^0.18.0",
"pg": "8.13.1", "pg": "8.13.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"stripe": "^22.0.2", "stripe": "^22.0.2",
"zod": "3.24.1" "zod": "3.24.1"
}, },
@@ -32,7 +35,9 @@
"@types/express": "4.17.21", "@types/express": "4.17.21",
"@types/morgan": "1.9.9", "@types/morgan": "1.9.9",
"@types/node": "22.10.2", "@types/node": "22.10.2",
"@types/pdfkit": "^0.17.6",
"@types/pg": "8.11.10", "@types/pg": "8.11.10",
"@types/qrcode": "^1.5.6",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "5.7.2" "typescript": "5.7.2"
} }
+231 -3
View File
@@ -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,
@@ -35,6 +36,7 @@ import {
completePendingBirdTransfersForOwner, completePendingBirdTransfersForOwner,
createBird, createBird,
createBirdMilestoneReminderDelivery, createBirdMilestoneReminderDelivery,
createBirdTransferCode,
createMedicationForBird, createMedicationForBird,
createPendingBirdTransfer, createPendingBirdTransfer,
findBirdsByBandId, findBirdsByBandId,
@@ -45,6 +47,8 @@ import {
deleteVetVisitForBird, deleteVetVisitForBird,
getBirdById, getBirdById,
getBirdByPublicProfileCode, getBirdByPublicProfileCode,
getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listBirds, listBirds,
listDueBirdMilestoneReminders, listDueBirdMilestoneReminders,
listMemorializedBirds, listMemorializedBirds,
@@ -53,6 +57,7 @@ import {
listVetVisitsForBird, listVetVisitsForBird,
listWeightsForBird, listWeightsForBird,
memorializeBird, memorializeBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace, transferBirdToWorkspace,
updateBird, updateBird,
updateMemorialReminderPreference, updateMemorialReminderPreference,
@@ -146,6 +151,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 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) {
@@ -244,6 +250,7 @@ const lostBirdReportSchema = z.object({
}); });
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/); const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
const birdTransferCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{12,32}$/);
const birdProfileListSchema = z const birdProfileListSchema = z
.string() .string()
.trim() .trim()
@@ -262,6 +269,10 @@ const birdSchema = z.object({
motivators: birdProfileListSchema, motivators: birdProfileListSchema,
demotivators: birdProfileListSchema, demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(), gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')),
@@ -617,6 +628,10 @@ const normalizeBird = (row: BirdRow) => ({
motivators: row.motivators, motivators: row.motivators,
demotivators: row.demotivators, demotivators: row.demotivators,
favoriteSnack: row.favorite_snack, favoriteSnack: row.favorite_snack,
vetClinicName: row.vet_clinic_name,
vetClinicAddress: row.vet_clinic_address,
vetAccountNumber: row.vet_account_number,
vetDoctorName: row.vet_doctor_name,
gender: row.gender, gender: row.gender,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day, gotchaDay: row.gotcha_day,
@@ -638,6 +653,35 @@ const normalizeBird = (row: BirdRow) => ({
latestRecordedOn: row.latest_recorded_on, latestRecordedOn: row.latest_recorded_on,
}); });
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
const ensureOpenBirdTransferCode = async (birdId: string, sourceWorkspaceId: number, requestedByUserId: string) => {
const existingTransferCode = await getOpenBirdTransferCodeForBird(birdId, sourceWorkspaceId);
if (existingTransferCode) {
return existingTransferCode;
}
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await createBirdTransferCode({
code: createBirdTransferCodeValue(),
birdId,
sourceWorkspaceId,
requestedByUserId,
});
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
continue;
}
throw error;
}
}
return null;
};
const normalizePublicBirdProfile = (row: BirdRow) => ({ const normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id, id: row.id,
workspaceId: row.workspace_id, workspaceId: row.workspace_id,
@@ -767,6 +811,7 @@ app.disable('x-powered-by');
app.use(helmet({ crossOriginResourcePolicy: false })); app.use(helmet({ crossOriginResourcePolicy: false }));
app.use( app.use(
cors({ cors({
exposedHeaders: ['X-FlockPal-Transfer-Code'],
origin(origin, callback) { origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) { if (!origin || allowedOrigins.includes(origin)) {
callback(null, true); callback(null, true);
@@ -3118,6 +3163,10 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
motivators: emptyToNull(parsed.data.motivators), motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators), demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack), favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -3142,7 +3191,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' }); res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return; return;
} }
@@ -3224,7 +3273,7 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' }); res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return; return;
} }
@@ -3232,6 +3281,181 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
} }
}); });
app.post(
'/api/birds/:birdId/transfer-code',
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;
}
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id);
if (!transferCode) {
throw new Error('Unable to create bird transfer code.');
}
await writeAuditLog(req.auth!, 'bird.transfer_code_created', 'bird', sourceBird.id, sourceBird.name, {
transferCodeId: transferCode.id,
});
res.status(201).json({
transferCode: {
code: transferCode.code,
bird: normalizeBird(sourceBird),
},
});
} catch (error) {
next(error);
}
},
);
app.get('/api/birds/:birdId/transfer-code', 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;
}
const transferCode = await getOpenBirdTransferCodeForBird(sourceBird.id, req.auth!.workspace.id);
res.json({
transferCode: transferCode
? {
code: transferCode.code,
bird: normalizeBird(sourceBird),
}
: null,
});
} catch (error) {
next(error);
}
});
app.post(
'/api/bird-transfer-codes/:code/accept',
requireAuth,
requireWriteAccess,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdTransferCodeSchema.safeParse(req.params.code);
if (!parsed.success) {
res.status(404).json({ error: 'Bird transfer code not found.' });
return;
}
try {
const transferCode = await getOpenBirdTransferCode(parsed.data);
if (!transferCode) {
res.status(404).json({ error: 'Bird transfer code not found or already used.' });
return;
}
if (transferCode.source_workspace_id === req.auth!.workspace.id) {
res.status(409).json({ error: 'This bird is already in your active flock.' });
return;
}
const bird = await transferBirdToWorkspace(transferCode.id, transferCode.source_workspace_id, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird is no longer available for transfer.' });
return;
}
await markBirdTransferCodeCompleted(transferCode.transfer_code_id, req.auth!.workspace.id);
await writeAuditLog(req.auth!, 'bird.transfer_code_accepted', 'bird', bird.id, bird.name, {
sourceWorkspaceId: transferCode.source_workspace_id,
sourceWorkspaceName: transferCode.workspace_name,
transferCodeId: transferCode.transfer_code_id,
});
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
next(error);
}
},
);
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;
}
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id);
if (!transferCode) {
throw new Error('Unable to create bird transfer code.');
}
await adoptionReportQueueEvents.waitUntilReady();
const reportJob = await enqueueAdoptionReportJob({
birdId: sourceBird.id,
workspaceId: req.auth!.workspace.id,
transferCode: transferCode.code,
printFriendly: req.query.printFriendly === 'true',
});
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,
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) => { app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body); const parsed = birdSchema.safeParse(req.body);
@@ -3271,6 +3495,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators), motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators), demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack), favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -3301,7 +3529,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' }); res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return; return;
} }
+32 -2
View File
@@ -215,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000), motivators VARCHAR(1000),
demotivators VARCHAR(1000), demotivators VARCHAR(1000),
favorite_snack VARCHAR(160), favorite_snack VARCHAR(160),
vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120),
vet_doctor_name VARCHAR(160),
gender VARCHAR(16) NOT NULL DEFAULT 'unknown', gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE, date_of_birth DATE,
gotcha_day DATE, gotcha_day DATE,
@@ -239,6 +243,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown', ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE,
@@ -284,8 +292,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
DROP INDEX IF EXISTS idx_birds_workspace_tag_id; DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
ON birds (workspace_id, LOWER(tag_id)) ON birds (LOWER(BTRIM(tag_id)))
WHERE tag_id IS NOT NULL WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> '' AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none'); AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
@@ -338,6 +346,28 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (bird_id) ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
ON bird_transfer_codes (bird_id, created_at DESC)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
ON bird_transfer_codes (code)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes ( CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+43
View File
@@ -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();
};
+434
View File
@@ -0,0 +1,434 @@
import fs from 'fs';
import PDFDocument from 'pdfkit';
import QRCode from 'qrcode';
import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow } from '../types.js';
type AdoptionReportInput = {
bird: BirdRow;
weights: WeightRow[];
vetVisits: VetVisitRow[];
notes: FlockNoteRow[];
transferCode: string;
birdPhotoBuffer?: Buffer | null;
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 formatShortDate = (value: string | null) => {
if (!value) {
return 'No data yet';
}
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
};
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 measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
doc.font('Helvetica-Bold').fontSize(10);
const textHeight = doc.heightOfString(value, {
width: width - 16,
lineGap: 1,
});
return Math.max(minHeight, 27 + Math.min(textHeight, 38));
};
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height?: number) => {
const cardHeight = height ?? measureFactHeight(doc, value, width);
doc.roundedRect(x, y, width, cardHeight, 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,
height: cardHeight - 27,
lineGap: 1,
ellipsis: true,
});
return cardHeight;
};
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
doc.roundedRect(x, y, width, height, 6).fillAndStroke(colors.panel, colors.border);
doc.fillColor(colors.blue).fontSize(8).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
doc.fillColor(colors.ink).fontSize(9.2).font('Helvetica').text(value, x + 8, y + 23, {
width: width - 16,
height: height - 31,
ellipsis: true,
lineGap: 1.2,
});
};
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('#fffdf9', colors.border);
if (!plottedWeights.length) {
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 latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
const earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`);
const startDate = new Date(latestDate);
startDate.setUTCDate(startDate.getUTCDate() - 13);
if (earliestDate > startDate) {
startDate.setTime(earliestDate.getTime());
}
const visibleWeights = plottedWeights.filter((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return recordedOn >= startDate && recordedOn <= latestDate;
});
const rawMinWeight = Math.min(...visibleWeights.map((entry) => entry.numericWeight));
const rawMaxWeight = Math.max(...visibleWeights.map((entry) => entry.numericWeight));
const rangePadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - rangePadding);
const maxWeight = rawMaxWeight + rangePadding;
const weightRange = Math.max(1, maxWeight - minWeight);
const padding = { top: 16, right: 18, bottom: 32, left: 48 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const startMs = startDate.getTime();
const endMs = latestDate.getTime();
const dateRange = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
const chartColor = /^#[0-9a-fA-F]{6}$/.test(birdColor) ? birdColor : colors.green;
const midWeight = minWeight + (maxWeight - minWeight) / 2;
const midDate = new Date((startMs + endMs) / 2);
const yTicks = [
{ label: `${maxWeight.toFixed(0)} g`, y: y + padding.top },
{ label: `${midWeight.toFixed(0)} g`, y: y + padding.top + plotHeight / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: y + padding.top + plotHeight },
];
const xTicks = [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: x + padding.left },
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth / 2 },
{ label: formatShortDate(latestDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth },
];
const points = visibleWeights.map((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return {
...entry,
x: x + padding.left + ((recordedOn.getTime() - startMs) / dateRange) * plotWidth,
y: y + padding.top + (1 - (entry.numericWeight - minWeight) / weightRange) * plotHeight,
};
});
doc.font('Helvetica').fontSize(7).fillColor(colors.muted);
yTicks.forEach((tick) => {
doc.text(tick.label, x + 4, tick.y - 3, { width: padding.left - 12, align: 'right' });
doc
.save()
.dash(4, { space: 6 })
.strokeColor('#d8e5ef')
.lineWidth(0.8)
.moveTo(x + padding.left, tick.y)
.lineTo(x + width - padding.right, tick.y)
.stroke()
.restore();
});
doc.strokeColor('#c7cdca').lineWidth(1).moveTo(x + padding.left, y + padding.top + plotHeight).lineTo(x + width - padding.right, y + padding.top + plotHeight).stroke();
xTicks.forEach((tick) => {
doc.fillColor(colors.muted).fontSize(7).text(tick.label, tick.x - 28, y + height - 18, { width: 56, align: 'center' });
});
points.forEach((entry, index) => {
if (index === 0) {
doc.moveTo(entry.x, entry.y);
} else {
doc.lineTo(entry.x, entry.y);
}
});
if (points.length > 1) {
doc.lineCap('round').strokeColor(chartColor).lineWidth(2.4).stroke();
}
points.forEach((entry) => {
doc.circle(entry.x, entry.y, 3.5).fillAndStroke(chartColor, '#fffdf9');
});
const latestPoint = points[points.length - 1];
const calloutOnLeft = latestPoint.x > x + width - padding.right - 84;
const calloutX = calloutOnLeft ? latestPoint.x - 82 : latestPoint.x + 8;
const calloutY = latestPoint.y < y + padding.top + 18 ? latestPoint.y + 8 : latestPoint.y - 22;
doc.roundedRect(calloutX, calloutY, 74, 18, 5).fillAndStroke('#fffdf9', '#d9dedb');
doc.fillColor(colors.ink).font('Helvetica-Bold').fontSize(7.5).text(`Latest ${formatWeight(latestPoint.numericWeight)}`, calloutX + 5, calloutY + 5, {
width: 64,
align: 'center',
});
};
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,
transferCode,
birdPhotoBuffer = null,
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 = birdPhotoBuffer ?? dataUrlToBuffer(bird.photo_data_url);
const contentWidth = page.width - page.margin * 2;
const headerY = page.margin;
const headerHeight = 136;
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 - 132;
const qrWidth = 124;
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 7, { width: qrWidth, align: 'center' });
if (wordmarkPath) {
doc.image(wordmarkPath, qrX + 7, headerY + 18, { fit: [110, 34], align: 'center', valign: 'center' });
}
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(7.5).text('Keep my story growing', qrX, headerY + 51, {
width: qrWidth,
align: 'center',
});
if (qrBuffer) {
doc.image(qrBuffer, qrX + 37, headerY + 62, { width: 50 });
}
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(6.8).text('Scan to continue tracking in FlockPal', qrX, headerY + 114, {
width: qrWidth,
align: 'center',
});
doc.fillColor(colors.ink).font('Helvetica').fontSize(6.5).text(transferCode, qrX, headerY + 126, { width: qrWidth, align: 'center' });
let y = headerY + headerHeight + 16;
const factGap = 8;
const factWidth = (contentWidth - factGap) / 2;
const facts = [
['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'],
];
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);
drawTextCard(doc, 'Motivators', motivators.length ? motivators.join(', ') : 'Not recorded', page.margin, y, factWidth);
drawTextCard(
doc,
'Demotivators',
demotivators.length ? demotivators.join(', ') : 'Not recorded',
page.margin + factWidth + factGap,
y,
factWidth,
);
y += 72;
if (y > 610) {
doc.addPage();
y = page.margin;
}
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
y += 50;
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
y += clinicAddressHeight + 7;
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
y += 50;
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 (y > 575) {
doc.addPage();
y = page.margin;
}
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 (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;
};
+103
View File
@@ -0,0 +1,103 @@
import path from 'path';
import sharp from 'sharp';
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 = 14;
const parseDataImage = (value: string | null) => {
if (!value) {
return null;
}
const match = value.match(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,(.+)$/);
return match ? Buffer.from(match[1], 'base64') : null;
};
const normalizeReportPhotoBuffer = async (imageBuffer: Buffer | null) => {
if (!imageBuffer) {
return null;
}
try {
return await sharp(imageBuffer).rotate().png().toBuffer();
} catch (error) {
console.warn('Unable to normalize bird photo for adoption report:', error);
return null;
}
};
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
if (!bird.photo_object_key) {
return normalizeReportPhotoBuffer(parseDataImage(bird.photo_data_url));
}
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;
}
return normalizeReportPhotoBuffer(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'),
},
});
};
@@ -6,7 +6,10 @@ import {
createBird, createBird,
createPendingBirdTransfer, createPendingBirdTransfer,
getBirdById, getBirdById,
getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listWeightsForBird, listWeightsForBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace, transferBirdToWorkspace,
} from './birdRepository.js'; } from './birdRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
@@ -198,3 +201,36 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[2].params, ['transfer-1', 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/); assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
}); });
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
const { calls } = mockDb({ rowCount: 0, rows: [] });
const transferCode = await getOpenBirdTransferCode('ADOPT-123');
assert.equal(transferCode, null);
assert.deepEqual(calls[0].params, ['ADOPT-123']);
assert.match(calls[0].text, /bird_transfer_codes\.completed_at IS NULL/);
assert.match(calls[0].text, /bird_transfer_codes\.revoked_at IS NULL/);
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
});
test('getOpenBirdTransferCodeForBird ignores consumed codes', async () => {
const { calls } = mockDb({ rowCount: 0, rows: [] });
const transferCode = await getOpenBirdTransferCodeForBird('bird-1', 10);
assert.equal(transferCode, null);
assert.deepEqual(calls[0].params, ['bird-1', 10]);
assert.match(calls[0].text, /completed_at IS NULL/);
assert.match(calls[0].text, /revoked_at IS NULL/);
});
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
const { calls } = mockDb({ rowCount: 1, rows: [] });
await markBirdTransferCodeCompleted('code-1', 22);
assert.deepEqual(calls[0].params, ['code-1', 22]);
assert.match(calls[0].text, /SET completed_at = CURRENT_TIMESTAMP/);
assert.match(calls[0].text, /completed_workspace_id = \$2/);
});
+157 -21
View File
@@ -5,6 +5,7 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationDoseScheduleItem, MedicationDoseScheduleItem,
@@ -23,6 +24,10 @@ const birdSelectFields = `
birds.motivators, birds.motivators,
birds.demotivators, birds.demotivators,
birds.favorite_snack, birds.favorite_snack,
birds.vet_clinic_name,
birds.vet_clinic_address,
birds.vet_account_number,
birds.vet_doctor_name,
birds.gender, birds.gender,
birds.date_of_birth::text, birds.date_of_birth::text,
birds.gotcha_day::text, birds.gotcha_day::text,
@@ -287,6 +292,10 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
vetDoctorName = null,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -308,6 +317,10 @@ export const createBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
vetClinicName?: string | null;
vetClinicAddress?: string | null;
vetAccountNumber?: string | null;
vetDoctorName?: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
@@ -322,9 +335,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean; publicProfileEnabled?: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[ [
birdId ?? null, birdId ?? null,
workspaceId, workspaceId,
@@ -334,6 +347,10 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -361,6 +378,10 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -382,6 +403,10 @@ export const updateBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
vetDoctorName: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
@@ -403,22 +428,26 @@ export const updateBird = async ({
motivators = $5, motivators = $5,
demotivators = $6, demotivators = $6,
favorite_snack = $7, favorite_snack = $7,
gender = $8, vet_clinic_name = $8,
date_of_birth = $9, vet_clinic_address = $9,
gotcha_day = $10, vet_account_number = $10,
chart_color = $11, vet_doctor_name = $11,
photo_data_url = $12, gender = $12,
photo_object_key = $13, date_of_birth = $13,
photo_content_type = $14, gotcha_day = $14,
photo_updated_at = $15, chart_color = $15,
notify_on_dob = $16, photo_data_url = $16,
notify_on_gotcha_day = $17, photo_object_key = $17,
public_profile_code = $18, photo_content_type = $18,
public_profile_enabled = $19 photo_updated_at = $19,
notify_on_dob = $20,
notify_on_gotcha_day = $21,
public_profile_code = $22,
public_profile_enabled = $23
WHERE id = $1 WHERE id = $1
AND workspace_id = $20 AND workspace_id = $24
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -441,6 +470,10 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -482,7 +515,7 @@ export const memorializeBird = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -518,7 +551,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NOT NULL AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -558,7 +591,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -659,7 +692,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
failed += 1; failed += 1;
const message = const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505' typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.' ? 'That band/tag ID is already in use in FlockPal.'
: error instanceof Error : error instanceof Error
? error.message ? error.message
: 'Unable to complete pending bird transfer.'; : 'Unable to complete pending bird transfer.';
@@ -670,6 +703,109 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
return { completed, failed }; return { completed, failed };
}; };
export const createBirdTransferCode = async ({
code,
birdId,
sourceWorkspaceId,
requestedByUserId,
}: {
code: string;
birdId: string;
sourceWorkspaceId: number;
requestedByUserId: string;
}) => {
await db.query(
`UPDATE bird_transfer_codes
SET revoked_at = CURRENT_TIMESTAMP
WHERE bird_id = $1
AND source_workspace_id = $2
AND completed_at IS NULL
AND revoked_at IS NULL`,
[birdId, sourceWorkspaceId],
);
const result = await db.query<BirdTransferCodeRow>(
`INSERT INTO bird_transfer_codes (code, bird_id, source_workspace_id, requested_by_user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at`,
[code, birdId, sourceWorkspaceId, requestedByUserId],
);
return result.rows[0] ?? null;
};
export const getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => {
const result = await db.query<BirdTransferCodeRow>(
`SELECT id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at
FROM bird_transfer_codes
WHERE bird_id = $1
AND source_workspace_id = $2
AND completed_at IS NULL
AND revoked_at IS NULL
ORDER BY created_at DESC
LIMIT 1`,
[birdId, sourceWorkspaceId],
);
return result.rows[0] ?? null;
};
export const getOpenBirdTransferCode = async (code: string) => {
const result = await db.query<
BirdRow & {
transfer_code_id: string;
code: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
transfer_code_created_at: string;
workspace_name: string;
}
>(
`SELECT
bird_transfer_codes.id AS transfer_code_id,
bird_transfer_codes.code,
bird_transfer_codes.source_workspace_id,
bird_transfer_codes.requested_by_user_id,
bird_transfer_codes.completed_at::text,
bird_transfer_codes.completed_workspace_id,
bird_transfer_codes.revoked_at::text,
bird_transfer_codes.created_at AS transfer_code_created_at,
workspaces.name AS workspace_name,
${birdSelectFields}
FROM bird_transfer_codes
INNER JOIN birds ON birds.id = bird_transfer_codes.bird_id
INNER JOIN workspaces ON workspaces.id = bird_transfer_codes.source_workspace_id
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE bird_transfer_codes.code = $1
AND bird_transfer_codes.completed_at IS NULL
AND bird_transfer_codes.revoked_at IS NULL
AND birds.workspace_id = bird_transfer_codes.source_workspace_id
AND birds.memorialized_at IS NULL`,
[code],
);
return result.rows[0] ?? null;
};
export const markBirdTransferCodeCompleted = async (codeId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE bird_transfer_codes
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2
WHERE id = $1`,
[codeId, completedWorkspaceId],
);
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => { export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>( const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes `SELECT id, bird_id, weight_grams, recorded_on::text, notes
+16
View File
@@ -101,6 +101,10 @@ export type BirdRow = {
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favorite_snack: string | null; favorite_snack: string | null;
vet_clinic_name: string | null;
vet_clinic_address: string | null;
vet_account_number: string | null;
vet_doctor_name: string | null;
gender: BirdGender; gender: BirdGender;
date_of_birth: string | null; date_of_birth: string | null;
gotcha_day: string | null; gotcha_day: string | null;
@@ -158,6 +162,18 @@ export type PendingBirdTransferRow = {
created_at: string; created_at: string;
}; };
export type BirdTransferCodeRow = {
id: string;
code: string;
bird_id: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
created_at: string;
};
export type WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
+29
View File
@@ -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);
}; };
+1
View File
@@ -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:-}
+1
View File
@@ -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:-}
+43 -1
View File
@@ -208,6 +208,10 @@ Role requirements are called out per endpoint below. If the signed-in member lac
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female", "gender": "female",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
@@ -793,6 +797,10 @@ Request body:
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female", "gender": "female",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
@@ -805,7 +813,7 @@ Request body:
Notes: Notes:
- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings - `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings
- `chartColor` defaults to `#cb3a35` - `chartColor` defaults to `#cb3a35`
Response `201`: Response `201`:
@@ -889,6 +897,40 @@ Possible errors:
- `409` if that owner email owns more than one receiving flock - `409` if that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId` - `409` if the destination flock already has a bird using the same `tagId`
#### `POST /api/birds/:birdId/transfer-code`
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
Response `201`:
```json
{
"transferCode": {
"code": "secure-code",
"bird": {}
}
}
```
#### `POST /api/bird-transfer-codes/:code/accept`
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
Response `200`:
```json
{
"bird": {},
"sourceWorkspaceName": "Previous Flock",
"workspace": {}
}
```
Possible errors:
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
#### `DELETE /api/birds/:birdId` #### `DELETE /api/birds/:birdId`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird. Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+880 -52
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+39
View File
@@ -1211,6 +1211,7 @@ textarea {
.bird-detail-tab .info-tab-icon, .bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon, .bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon,
.bird-detail-tab .audit-tab-icon, .bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon { .bird-detail-tab .vet-tab-icon {
width: 24px; width: 24px;
@@ -1706,6 +1707,44 @@ label {
accent-color: var(--accent-green); accent-color: var(--accent-green);
} }
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: min(100%, 420px);
margin: 0.35rem 0 0;
padding: 0.85rem 1rem;
border: 1px solid rgba(53, 129, 98, 0.24);
border-radius: 16px;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
}
.checkbox-row input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 auto;
margin: 0.1rem 0 0;
padding: 0;
accent-color: var(--accent-green);
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
line-height: 1.3;
}
.checkbox-row strong {
color: var(--ink);
font-size: 0.98rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
}
.primary-button { .primary-button {
border: 0; border: 0;
border-radius: 18px; border-radius: 18px;