Files
FlockPal/backend/src/repositories/birdRepository.ts
T
2026-04-21 15:04:44 -04:00

644 lines
20 KiB
TypeScript

import { db } from '../db/client.js';
import type {
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType,
BirdRow,
LostBirdMatchRow,
MedicationAdministrationRow,
MedicationDoseScheduleItem,
MedicationRow,
PendingBirdTransferRow,
VetVisitRow,
WeightRow,
} from '../types.js';
const birdSelectFields = `
birds.id,
birds.workspace_id,
birds.name,
birds.tag_id,
birds.species,
birds.gender,
birds.date_of_birth::text,
birds.gotcha_day::text,
birds.chart_color,
birds.photo_data_url,
birds.notify_on_dob,
birds.notify_on_gotcha_day,
birds.created_at,
latest.weight_grams AS latest_weight_grams,
latest.recorded_on::text AS latest_recorded_on
`;
export const getBirdById = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdRow>(
`SELECT
${birdSelectFields}
FROM birds
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 birds.id = $1
AND birds.workspace_id = $2`,
[birdId, workspaceId],
);
return result.rows[0] ?? null;
};
export const listBirds = async (workspaceId: number) => {
const result = await db.query<BirdRow>(
`SELECT
${birdSelectFields}
FROM birds
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 birds.workspace_id = $1
ORDER BY birds.name ASC`,
[workspaceId],
);
return result.rows;
};
export const findBirdsByBandId = async (tagId: string) => {
const result = await db.query<LostBirdMatchRow>(
`SELECT
${birdSelectFields},
workspaces.name AS workspace_name,
workspaces.billing_email AS workspace_billing_email
FROM birds
INNER JOIN workspaces ON workspaces.id = birds.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 LOWER(birds.tag_id) = LOWER($1)
ORDER BY birds.created_at ASC
LIMIT 10`,
[tagId],
);
return result.rows;
};
export const listDueBirdMilestoneReminders = async (runDate: string) => {
const result = await db.query<BirdMilestoneReminderCandidateRow>(
`WITH reminder_context AS (
SELECT $1::date AS run_date,
EXTRACT(YEAR FROM $1::date)::int AS reminder_year
)
SELECT
${birdSelectFields},
workspaces.name AS workspace_name,
'hatch_day'::text AS reminder_type,
birds.date_of_birth::text AS reminder_date,
reminder_context.reminder_year
FROM birds
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
CROSS JOIN reminder_context
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 birds.notify_on_dob = TRUE
AND birds.date_of_birth IS NOT NULL
AND EXTRACT(MONTH FROM birds.date_of_birth) = EXTRACT(MONTH FROM reminder_context.run_date)
AND EXTRACT(DAY FROM birds.date_of_birth) = EXTRACT(DAY FROM reminder_context.run_date)
AND NOT EXISTS (
SELECT 1
FROM bird_milestone_reminder_deliveries deliveries
WHERE deliveries.bird_id = birds.id
AND deliveries.reminder_type = 'hatch_day'
AND deliveries.reminder_year = reminder_context.reminder_year
)
UNION ALL
SELECT
${birdSelectFields},
workspaces.name AS workspace_name,
'gotcha_day'::text AS reminder_type,
birds.gotcha_day::text AS reminder_date,
reminder_context.reminder_year
FROM birds
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
CROSS JOIN reminder_context
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 birds.notify_on_gotcha_day = TRUE
AND birds.gotcha_day IS NOT NULL
AND EXTRACT(MONTH FROM birds.gotcha_day) = EXTRACT(MONTH FROM reminder_context.run_date)
AND EXTRACT(DAY FROM birds.gotcha_day) = EXTRACT(DAY FROM reminder_context.run_date)
AND NOT EXISTS (
SELECT 1
FROM bird_milestone_reminder_deliveries deliveries
WHERE deliveries.bird_id = birds.id
AND deliveries.reminder_type = 'gotcha_day'
AND deliveries.reminder_year = reminder_context.reminder_year
)
ORDER BY workspace_name ASC, name ASC, reminder_type ASC`,
[runDate],
);
return result.rows;
};
export const createBirdMilestoneReminderDelivery = async ({
birdId,
workspaceId,
reminderType,
reminderYear,
deliveredOn,
}: {
birdId: string;
workspaceId: number;
reminderType: BirdMilestoneReminderType;
reminderYear: number;
deliveredOn: string;
}) => {
const result = await db.query<BirdMilestoneReminderDeliveryRow>(
`INSERT INTO bird_milestone_reminder_deliveries (bird_id, workspace_id, reminder_type, reminder_year, delivered_on)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (bird_id, reminder_type, reminder_year) DO NOTHING
RETURNING id, bird_id, workspace_id, reminder_type, reminder_year, delivered_on::text, created_at`,
[birdId, workspaceId, reminderType, reminderYear, deliveredOn],
);
return result.rows[0] ?? null;
};
export const createBird = async ({
workspaceId,
name,
tagId,
species,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
notifyOnDob,
notifyOnGotchaDay,
}: {
workspaceId: number;
name: string;
tagId: string;
species: string;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
chartColor: string;
photoDataUrl: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
}) => {
const result = await db.query<BirdRow>(
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
);
return result.rows[0] ?? null;
};
export const updateBird = async ({
birdId,
workspaceId,
name,
tagId,
species,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
notifyOnDob,
notifyOnGotchaDay,
}: {
birdId: string;
workspaceId: number;
name: string;
tagId: string;
species: string;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
chartColor: string;
photoDataUrl: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
}) => {
const result = await db.query<BirdRow>(
`UPDATE birds
SET name = $2,
tag_id = $3,
species = $4,
gender = $5,
date_of_birth = $6,
gotcha_day = $7,
chart_color = $8,
photo_data_url = $9,
notify_on_dob = $10,
notify_on_gotcha_day = $11
WHERE id = $1
AND workspace_id = $12
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_weight_grams,
(
SELECT recorded_on::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId],
);
return result.rows[0] ?? null;
};
export const deleteBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<{ id: string }>(
`DELETE FROM birds
WHERE id = $1
AND workspace_id = $2
RETURNING id`,
[birdId, workspaceId],
);
return Boolean(result.rowCount);
};
export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: number, targetWorkspaceId: number) => {
const result = await db.query<BirdRow>(
`UPDATE birds
SET workspace_id = $3
WHERE id = $1
AND workspace_id = $2
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_weight_grams,
(
SELECT recorded_on::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[birdId, sourceWorkspaceId, targetWorkspaceId],
);
return result.rows[0] ?? null;
};
export const createPendingBirdTransfer = async ({
birdId,
sourceWorkspaceId,
destinationOwnerEmail,
requestedByUserId,
}: {
birdId: string;
sourceWorkspaceId: number;
destinationOwnerEmail: string;
requestedByUserId: string;
}) => {
const result = await db.query<PendingBirdTransferRow>(
`INSERT INTO pending_bird_transfers (bird_id, source_workspace_id, destination_owner_email, requested_by_user_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (bird_id) WHERE completed_at IS NULL DO UPDATE
SET destination_owner_email = EXCLUDED.destination_owner_email,
requested_by_user_id = EXCLUDED.requested_by_user_id,
last_error = NULL,
created_at = CURRENT_TIMESTAMP
RETURNING id, bird_id, source_workspace_id, destination_owner_email, requested_by_user_id, completed_at::text, completed_workspace_id, last_error, created_at`,
[birdId, sourceWorkspaceId, destinationOwnerEmail, requestedByUserId],
);
return result.rows[0] ?? null;
};
export const listPendingBirdTransfersForOwnerEmail = async (ownerEmail: string) => {
const result = await db.query<PendingBirdTransferRow>(
`SELECT id, bird_id, source_workspace_id, destination_owner_email, requested_by_user_id, completed_at::text, completed_workspace_id, last_error, created_at
FROM pending_bird_transfers
WHERE LOWER(destination_owner_email) = LOWER($1)
AND completed_at IS NULL
ORDER BY created_at ASC`,
[ownerEmail],
);
return result.rows;
};
export const markPendingBirdTransferCompleted = async (transferId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE pending_bird_transfers
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2,
last_error = NULL
WHERE id = $1`,
[transferId, completedWorkspaceId],
);
};
export const markPendingBirdTransferFailed = async (transferId: string, lastError: string) => {
await db.query(
`UPDATE pending_bird_transfers
SET last_error = $2
WHERE id = $1`,
[transferId, lastError],
);
};
export const completePendingBirdTransfersForOwner = async (ownerEmail: string, targetWorkspaceId: number) => {
const transfers = await listPendingBirdTransfersForOwnerEmail(ownerEmail);
let completed = 0;
let failed = 0;
for (const transfer of transfers) {
try {
const bird = await transferBirdToWorkspace(transfer.bird_id, transfer.source_workspace_id, targetWorkspaceId);
if (!bird) {
failed += 1;
await markPendingBirdTransferFailed(transfer.id, 'Bird is no longer available in the source flock.');
continue;
}
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
completed += 1;
} catch (error) {
failed += 1;
const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.'
: error instanceof Error
? error.message
: 'Unable to complete pending bird transfer.';
await markPendingBirdTransferFailed(transfer.id, message);
}
}
return { completed, failed };
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
FROM weight_records
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = weight_records.bird_id
AND birds.workspace_id = $3
)
AND recorded_on >= CURRENT_DATE - (($2::int - 1) * INTERVAL '1 day')
ORDER BY recorded_on ASC`,
[birdId, days, workspaceId],
);
return result.rows;
};
export const createWeightForBird = async (birdId: string, weightGrams: number, recordedOn: string, notes: string | null) => {
const result = await db.query<WeightRow>(
`INSERT INTO weight_records (bird_id, weight_grams, recorded_on, notes)
VALUES ($1, $2, $3, $4)
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
[birdId, weightGrams, recordedOn, notes],
);
return result.rows[0] ?? null;
};
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<VetVisitRow>(
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
FROM vet_visits
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = vet_visits.bird_id
AND birds.workspace_id = $2
)
ORDER BY visited_on DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const createVetVisitForBird = async (birdId: string, visitedOn: string, clinicName: string, reason: string, notes: string | null) => {
const result = await db.query<VetVisitRow>(
`INSERT INTO vet_visits (bird_id, visited_on, clinic_name, reason, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`,
[birdId, visitedOn, clinicName, reason, notes],
);
return result.rows[0] ?? null;
};
export const updateVetVisitForBird = async (
visitId: string,
birdId: string,
visitedOn: string,
clinicName: string,
reason: string,
notes: string | null,
) => {
const result = await db.query<VetVisitRow>(
`UPDATE vet_visits
SET visited_on = $3,
clinic_name = $4,
reason = $5,
notes = $6
WHERE id = $1
AND bird_id = $2
RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`,
[visitId, birdId, visitedOn, clinicName, reason, notes],
);
return result.rows[0] ?? null;
};
export const deleteVetVisitForBird = async (visitId: string, birdId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM vet_visits
WHERE id = $1
AND bird_id = $2
RETURNING id`,
[visitId, birdId],
);
return (result.rowCount ?? 0) > 0;
};
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<MedicationRow>(
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
FROM medications
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = medications.bird_id
AND birds.workspace_id = $2
)
ORDER BY COALESCE(end_date, '9999-12-31'::date) DESC, start_date DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const createMedicationForBird = async (
birdId: string,
name: string,
dosage: string,
frequency: string,
doseSchedule: MedicationDoseScheduleItem[],
route: string | null,
startDate: string,
endDate: string | null,
notes: string | null,
) => {
const result = await db.query<MedicationRow>(
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
);
return result.rows[0] ?? null;
};
export const updateMedicationForBird = async (
medicationId: string,
birdId: string,
name: string,
dosage: string,
frequency: string,
doseSchedule: MedicationDoseScheduleItem[],
route: string | null,
startDate: string,
endDate: string | null,
notes: string | null,
) => {
const result = await db.query<MedicationRow>(
`UPDATE medications
SET name = $3,
dosage = $4,
frequency = $5,
dose_schedule = $6,
route = $7,
start_date = $8,
end_date = $9,
notes = $10
WHERE id = $1
AND bird_id = $2
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
);
return result.rows[0] ?? null;
};
export const deleteMedicationForBird = async (medicationId: string, birdId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM medications
WHERE id = $1
AND bird_id = $2
RETURNING id`,
[medicationId, birdId],
);
return (result.rowCount ?? 0) > 0;
};
export const listMedicationAdministrationsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<MedicationAdministrationRow>(
`SELECT id, medication_id, bird_id, administered_on::text, administration_slot, status, notes, created_by_user_id, created_at
FROM medication_administrations
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = medication_administrations.bird_id
AND birds.workspace_id = $2
)
ORDER BY administered_on DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const upsertMedicationAdministrationForBird = async (
medicationId: string,
birdId: string,
workspaceId: number,
administeredOn: string,
administrationSlot: string,
status: 'administered' | 'missed',
notes: string | null,
createdByUserId: string | null,
) => {
const result = await db.query<MedicationAdministrationRow>(
`INSERT INTO medication_administrations (medication_id, bird_id, administered_on, administration_slot, status, notes, created_by_user_id)
SELECT $1, $2, $4, $5, $6, $7, $8
WHERE EXISTS (
SELECT 1
FROM medications
JOIN birds ON birds.id = medications.bird_id
WHERE medications.id = $1
AND medications.bird_id = $2
AND birds.workspace_id = $3
)
ON CONFLICT (medication_id, administered_on, administration_slot)
DO UPDATE SET status = EXCLUDED.status,
notes = EXCLUDED.notes,
created_by_user_id = EXCLUDED.created_by_user_id,
created_at = CURRENT_TIMESTAMP
RETURNING id, medication_id, bird_id, administered_on::text, administration_slot, status, notes, created_by_user_id, created_at`,
[medicationId, birdId, workspaceId, administeredOn, administrationSlot, status, notes, createdByUserId],
);
return result.rows[0] ?? null;
};