diff --git a/backend/src/app.ts b/backend/src/app.ts index 2d3d039..026ad31 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -48,6 +48,7 @@ import { getBirdById, getBirdByPublicProfileCode, getOpenBirdTransferCode, + getOpenBirdTransferCodeForBird, listBirds, listDueBirdMilestoneReminders, listMemorializedBirds, @@ -654,6 +655,33 @@ const normalizeBird = (row: BirdRow) => ({ 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) => ({ id: row.id, workspaceId: row.workspace_id, @@ -3272,25 +3300,7 @@ app.post( return; } - let transferCode = null; - - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - transferCode = await createBirdTransferCode({ - code: createBirdTransferCodeValue(), - birdId: sourceBird.id, - sourceWorkspaceId: req.auth!.workspace.id, - requestedByUserId: req.auth!.user.id, - }); - break; - } catch (error) { - if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) { - continue; - } - - throw error; - } - } + 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.'); @@ -3312,6 +3322,30 @@ app.post( }, ); +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, @@ -3384,25 +3418,7 @@ app.post( return; } - let transferCode = null; - - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - transferCode = await createBirdTransferCode({ - code: createBirdTransferCodeValue(), - birdId: sourceBird.id, - sourceWorkspaceId: req.auth!.workspace.id, - requestedByUserId: req.auth!.user.id, - }); - break; - } catch (error) { - if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) { - continue; - } - - throw error; - } - } + 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.'); diff --git a/backend/src/reports/adoptionReport.ts b/backend/src/reports/adoptionReport.ts index 1e1ff8e..127033b 100644 --- a/backend/src/reports/adoptionReport.ts +++ b/backend/src/reports/adoptionReport.ts @@ -154,8 +154,12 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi } 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() - 29); + 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; diff --git a/backend/src/reports/adoptionReportJob.ts b/backend/src/reports/adoptionReportJob.ts index 489d13d..49fb0ad 100644 --- a/backend/src/reports/adoptionReportJob.ts +++ b/backend/src/reports/adoptionReportJob.ts @@ -12,7 +12,7 @@ import { getSignedS3ObjectUrl } from '../storage/s3Client.js'; import type { BirdRow } from '../types.js'; import { renderAdoptionReportPdf } from './adoptionReport.js'; -const adoptionReportWeightHistoryDays = 425; +const adoptionReportWeightHistoryDays = 14; const parseDataImage = (value: string | null) => { if (!value) { diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index c3733dd..85e314a 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -734,6 +734,22 @@ export const createBirdTransferCode = async ({ return result.rows[0] ?? null; }; +export const getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => { + const result = await db.query( + `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 & { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69a16b5..f4b2df4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1665,6 +1665,46 @@ function App() { setEditingVeterinaryInfo(false); }, [selectedBird]); + useEffect(() => { + const selectedBirdId = selectedBird?.id; + + if (!selectedBirdId || !authToken || adoptionTransferCodes[selectedBirdId]) { + return; + } + + let canceled = false; + + const loadOpenTransferCode = async () => { + try { + const response = await apiFetch(`/birds/${selectedBirdId}/transfer-code`, authToken); + + if (!response.ok) { + return; + } + + const data = + (await readJsonSafely<{ + transferCode?: { + code?: string; + } | null; + }>(response)) ?? {}; + const code = data.transferCode?.code; + + if (!canceled && code) { + setAdoptionTransferCodes((current) => ({ ...current, [selectedBirdId]: code })); + } + } catch { + // Transfer codes are optional until a report/code is created. + } + }; + + void loadOpenTransferCode(); + + return () => { + canceled = true; + }; + }, [adoptionTransferCodes, authToken, selectedBird?.id]); + const overviewWindowStartDate = useMemo(() => { const startDate = new Date(); startDate.setHours(0, 0, 0, 0);