1035 lines
32 KiB
TypeScript
1035 lines
32 KiB
TypeScript
import { db } from '../db/client.js';
|
|
import type {
|
|
BirdGender,
|
|
BirdMilestoneReminderCandidateRow,
|
|
BirdMilestoneReminderDeliveryRow,
|
|
BirdMilestoneReminderType,
|
|
BirdRow,
|
|
BirdTransferCodeRow,
|
|
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.motivators,
|
|
birds.demotivators,
|
|
birds.favorite_snack,
|
|
birds.vet_clinic_name,
|
|
birds.vet_clinic_address,
|
|
birds.vet_account_number,
|
|
birds.vet_doctor_name,
|
|
birds.gender,
|
|
birds.date_of_birth::text,
|
|
birds.gotcha_day::text,
|
|
birds.chart_color,
|
|
birds.photo_data_url,
|
|
birds.photo_object_key,
|
|
birds.photo_content_type,
|
|
birds.photo_updated_at,
|
|
birds.notify_on_dob,
|
|
birds.notify_on_gotcha_day,
|
|
birds.public_profile_code,
|
|
birds.public_profile_enabled,
|
|
birds.memorialized_at,
|
|
birds.memorialized_on::text,
|
|
birds.memorial_note,
|
|
birds.notify_on_memorial_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 getBirdByPublicProfileCode = async (publicProfileCode: string) => {
|
|
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.public_profile_code = $1
|
|
AND birds.public_profile_enabled = TRUE
|
|
AND birds.memorialized_at IS NULL`,
|
|
[publicProfileCode],
|
|
);
|
|
|
|
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
|
|
AND birds.memorialized_at IS NULL
|
|
ORDER BY birds.name ASC`,
|
|
[workspaceId],
|
|
);
|
|
|
|
return result.rows;
|
|
};
|
|
|
|
export const listMemorializedBirds = 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
|
|
AND birds.memorialized_at IS NOT NULL
|
|
ORDER BY birds.memorialized_on DESC NULLS LAST, 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 birds.tag_id IS NOT NULL
|
|
AND BTRIM(birds.tag_id) <> ''
|
|
AND LOWER(BTRIM(birds.tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
|
|
AND LOWER(birds.tag_id) = LOWER($1)
|
|
AND birds.memorialized_at IS NULL
|
|
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.memorialized_at IS NULL
|
|
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.memorialized_at IS NULL
|
|
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
|
|
)
|
|
UNION ALL
|
|
SELECT
|
|
${birdSelectFields},
|
|
workspaces.name AS workspace_name,
|
|
'memorial_day'::text AS reminder_type,
|
|
birds.memorialized_on::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_memorial_day = TRUE
|
|
AND birds.memorialized_at IS NOT NULL
|
|
AND birds.memorialized_on IS NOT NULL
|
|
AND EXTRACT(MONTH FROM birds.memorialized_on) = EXTRACT(MONTH FROM reminder_context.run_date)
|
|
AND EXTRACT(DAY FROM birds.memorialized_on) = 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 = 'memorial_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 ({
|
|
birdId,
|
|
workspaceId,
|
|
name,
|
|
tagId,
|
|
species,
|
|
motivators,
|
|
demotivators,
|
|
favoriteSnack,
|
|
vetClinicName = null,
|
|
vetClinicAddress = null,
|
|
vetAccountNumber = null,
|
|
vetDoctorName = null,
|
|
gender,
|
|
dateOfBirth,
|
|
gotchaDay,
|
|
chartColor,
|
|
photoDataUrl,
|
|
photoObjectKey = null,
|
|
photoContentType = null,
|
|
photoUpdatedAt = null,
|
|
notifyOnDob,
|
|
notifyOnGotchaDay,
|
|
publicProfileCode = null,
|
|
publicProfileEnabled = false,
|
|
}: {
|
|
birdId?: string;
|
|
workspaceId: number;
|
|
name: string;
|
|
tagId: string | null;
|
|
species: string;
|
|
motivators: string | null;
|
|
demotivators: string | null;
|
|
favoriteSnack: string | null;
|
|
vetClinicName?: string | null;
|
|
vetClinicAddress?: string | null;
|
|
vetAccountNumber?: string | null;
|
|
vetDoctorName?: string | null;
|
|
gender: BirdGender;
|
|
dateOfBirth: string | null;
|
|
gotchaDay: string | null;
|
|
chartColor: string;
|
|
photoDataUrl: string | null;
|
|
photoObjectKey?: string | null;
|
|
photoContentType?: string | null;
|
|
photoUpdatedAt?: string | null;
|
|
notifyOnDob: boolean;
|
|
notifyOnGotchaDay: boolean;
|
|
publicProfileCode?: string | null;
|
|
publicProfileEnabled?: boolean;
|
|
}) => {
|
|
const result = await db.query<BirdRow>(
|
|
`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, $21, $22, $23, $24)
|
|
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,
|
|
workspaceId,
|
|
name,
|
|
tagId,
|
|
species,
|
|
motivators,
|
|
demotivators,
|
|
favoriteSnack,
|
|
vetClinicName,
|
|
vetClinicAddress,
|
|
vetAccountNumber,
|
|
vetDoctorName,
|
|
gender,
|
|
dateOfBirth,
|
|
gotchaDay,
|
|
chartColor,
|
|
photoDataUrl,
|
|
photoObjectKey,
|
|
photoContentType,
|
|
photoUpdatedAt,
|
|
notifyOnDob,
|
|
notifyOnGotchaDay,
|
|
publicProfileCode,
|
|
publicProfileEnabled,
|
|
],
|
|
);
|
|
|
|
return result.rows[0] ?? null;
|
|
};
|
|
|
|
export const updateBird = async ({
|
|
birdId,
|
|
workspaceId,
|
|
name,
|
|
tagId,
|
|
species,
|
|
motivators,
|
|
demotivators,
|
|
favoriteSnack,
|
|
vetClinicName,
|
|
vetClinicAddress,
|
|
vetAccountNumber,
|
|
vetDoctorName,
|
|
gender,
|
|
dateOfBirth,
|
|
gotchaDay,
|
|
chartColor,
|
|
photoDataUrl,
|
|
photoObjectKey = null,
|
|
photoContentType = null,
|
|
photoUpdatedAt = null,
|
|
notifyOnDob,
|
|
notifyOnGotchaDay,
|
|
publicProfileCode,
|
|
publicProfileEnabled,
|
|
}: {
|
|
birdId: string;
|
|
workspaceId: number;
|
|
name: string;
|
|
tagId: string | null;
|
|
species: string;
|
|
motivators: string | null;
|
|
demotivators: string | null;
|
|
favoriteSnack: string | null;
|
|
vetClinicName: string | null;
|
|
vetClinicAddress: string | null;
|
|
vetAccountNumber: string | null;
|
|
vetDoctorName: string | null;
|
|
gender: BirdGender;
|
|
dateOfBirth: string | null;
|
|
gotchaDay: string | null;
|
|
chartColor: string;
|
|
photoDataUrl: string | null;
|
|
photoObjectKey?: string | null;
|
|
photoContentType?: string | null;
|
|
photoUpdatedAt?: string | null;
|
|
notifyOnDob: boolean;
|
|
notifyOnGotchaDay: boolean;
|
|
publicProfileCode: string | null;
|
|
publicProfileEnabled: boolean;
|
|
}) => {
|
|
const result = await db.query<BirdRow>(
|
|
`UPDATE birds
|
|
SET name = $2,
|
|
tag_id = $3,
|
|
species = $4,
|
|
motivators = $5,
|
|
demotivators = $6,
|
|
favorite_snack = $7,
|
|
vet_clinic_name = $8,
|
|
vet_clinic_address = $9,
|
|
vet_account_number = $10,
|
|
vet_doctor_name = $11,
|
|
gender = $12,
|
|
date_of_birth = $13,
|
|
gotcha_day = $14,
|
|
chart_color = $15,
|
|
photo_data_url = $16,
|
|
photo_object_key = $17,
|
|
photo_content_type = $18,
|
|
photo_updated_at = $19,
|
|
notify_on_dob = $20,
|
|
notify_on_gotcha_day = $21,
|
|
public_profile_code = $22,
|
|
public_profile_enabled = $23
|
|
WHERE id = $1
|
|
AND workspace_id = $24
|
|
AND memorialized_at IS NULL
|
|
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
|
|
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,
|
|
motivators,
|
|
demotivators,
|
|
favoriteSnack,
|
|
vetClinicName,
|
|
vetClinicAddress,
|
|
vetAccountNumber,
|
|
vetDoctorName,
|
|
gender,
|
|
dateOfBirth,
|
|
gotchaDay,
|
|
chartColor,
|
|
photoDataUrl,
|
|
photoObjectKey,
|
|
photoContentType,
|
|
photoUpdatedAt,
|
|
notifyOnDob,
|
|
notifyOnGotchaDay,
|
|
publicProfileCode,
|
|
publicProfileEnabled,
|
|
workspaceId,
|
|
],
|
|
);
|
|
|
|
return result.rows[0] ?? null;
|
|
};
|
|
|
|
export const memorializeBird = async ({
|
|
birdId,
|
|
workspaceId,
|
|
memorializedOn,
|
|
memorialNote,
|
|
notifyOnMemorialDay,
|
|
}: {
|
|
birdId: string;
|
|
workspaceId: number;
|
|
memorializedOn: string;
|
|
memorialNote: string | null;
|
|
notifyOnMemorialDay: boolean;
|
|
}) => {
|
|
const result = await db.query<BirdRow>(
|
|
`UPDATE birds
|
|
SET memorialized_at = CURRENT_TIMESTAMP,
|
|
memorialized_on = $3,
|
|
memorial_note = $4,
|
|
notify_on_memorial_day = $5
|
|
WHERE id = $1
|
|
AND workspace_id = $2
|
|
AND memorialized_at IS NULL
|
|
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
|
|
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, workspaceId, memorializedOn, memorialNote, notifyOnMemorialDay],
|
|
);
|
|
|
|
return result.rows[0] ?? null;
|
|
};
|
|
|
|
export const updateMemorialReminderPreference = async ({
|
|
birdId,
|
|
workspaceId,
|
|
notifyOnMemorialDay,
|
|
}: {
|
|
birdId: string;
|
|
workspaceId: number;
|
|
notifyOnMemorialDay: boolean;
|
|
}) => {
|
|
const result = await db.query<BirdRow>(
|
|
`UPDATE birds
|
|
SET notify_on_memorial_day = $3
|
|
WHERE id = $1
|
|
AND workspace_id = $2
|
|
AND memorialized_at IS NOT NULL
|
|
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
|
|
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, workspaceId, notifyOnMemorialDay],
|
|
);
|
|
|
|
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
|
|
AND memorialized_at IS NULL
|
|
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
|
|
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'
|
|
? 'That band/tag ID is already in use in FlockPal.'
|
|
: error instanceof Error
|
|
? error.message
|
|
: 'Unable to complete pending bird transfer.';
|
|
await markPendingBirdTransferFailed(transfer.id, message);
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
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;
|
|
};
|