6 Commits

Author SHA1 Message Date
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
9 changed files with 1218 additions and 79 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+137 -3
View File
@@ -35,6 +35,7 @@ import {
completePendingBirdTransfersForOwner,
createBird,
createBirdMilestoneReminderDelivery,
createBirdTransferCode,
createMedicationForBird,
createPendingBirdTransfer,
findBirdsByBandId,
@@ -45,6 +46,7 @@ import {
deleteVetVisitForBird,
getBirdById,
getBirdByPublicProfileCode,
getOpenBirdTransferCode,
listBirds,
listDueBirdMilestoneReminders,
listMemorializedBirds,
@@ -53,6 +55,7 @@ import {
listVetVisitsForBird,
listWeightsForBird,
memorializeBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace,
updateBird,
updateMemorialReminderPreference,
@@ -244,6 +247,7 @@ const lostBirdReportSchema = z.object({
});
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
.string()
.trim()
@@ -262,6 +266,10 @@ const birdSchema = z.object({
motivators: birdProfileListSchema,
demotivators: birdProfileListSchema,
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(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
@@ -617,6 +625,10 @@ const normalizeBird = (row: BirdRow) => ({
motivators: row.motivators,
demotivators: row.demotivators,
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,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
@@ -638,6 +650,8 @@ const normalizeBird = (row: BirdRow) => ({
latestRecordedOn: row.latest_recorded_on,
});
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
const normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id,
workspaceId: row.workspace_id,
@@ -3118,6 +3132,10 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
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,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -3142,7 +3160,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
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;
}
@@ -3224,7 +3242,7 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} 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 the destination flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
@@ -3232,6 +3250,118 @@ 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;
}
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;
}
}
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.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.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
@@ -3271,6 +3401,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
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,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -3301,7 +3435,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
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;
}
+32 -2
View File
@@ -215,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
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',
date_of_birth 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 demotivators VARCHAR(1000),
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 date_of_birth 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;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
ON birds (workspace_id, LOWER(tag_id))
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
ON birds (LOWER(BTRIM(tag_id)))
WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> ''
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)
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+141 -21
View File
@@ -5,6 +5,7 @@ import type {
BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType,
BirdRow,
BirdTransferCodeRow,
LostBirdMatchRow,
MedicationAdministrationRow,
MedicationDoseScheduleItem,
@@ -23,6 +24,10 @@ const birdSelectFields = `
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,
@@ -287,6 +292,10 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
vetDoctorName = null,
gender,
dateOfBirth,
gotchaDay,
@@ -308,6 +317,10 @@ export const createBird = async ({
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;
@@ -322,9 +335,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean;
}) => {
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)
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)
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`,
`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,
@@ -334,6 +347,10 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
@@ -361,6 +378,10 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
@@ -382,6 +403,10 @@ export const updateBird = async ({
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;
@@ -403,22 +428,26 @@ export const updateBird = async ({
motivators = $5,
demotivators = $6,
favorite_snack = $7,
gender = $8,
date_of_birth = $9,
gotcha_day = $10,
chart_color = $11,
photo_data_url = $12,
photo_object_key = $13,
photo_content_type = $14,
photo_updated_at = $15,
notify_on_dob = $16,
notify_on_gotcha_day = $17,
public_profile_code = $18,
public_profile_enabled = $19
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 = $20
AND workspace_id = $24
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
FROM weight_records
@@ -441,6 +470,10 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
@@ -482,7 +515,7 @@ export const memorializeBird = async ({
WHERE id = $1
AND workspace_id = $2
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
FROM weight_records
@@ -518,7 +551,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1
AND workspace_id = $2
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
FROM weight_records
@@ -558,7 +591,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1
AND workspace_id = $2
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
FROM weight_records
@@ -659,7 +692,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
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.'
? 'That band/tag ID is already in use in FlockPal.'
: error instanceof Error
? error.message
: 'Unable to complete pending bird transfer.';
@@ -670,6 +703,93 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
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 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
+16
View File
@@ -101,6 +101,10 @@ export type BirdRow = {
motivators: string | null;
demotivators: 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;
date_of_birth: string | null;
gotcha_day: string | null;
@@ -158,6 +162,18 @@ export type PendingBirdTransferRow = {
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 = {
id: string;
bird_id: string;
+43 -1
View File
@@ -208,6 +208,10 @@ Role requirements are called out per endpoint below. If the signed-in member lac
"name": "Kiwi",
"tagId": "FP-001",
"species": "Cockatiel",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female",
"dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21",
@@ -793,6 +797,10 @@ Request body:
"name": "Kiwi",
"tagId": "FP-001",
"species": "Cockatiel",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female",
"dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21",
@@ -805,7 +813,7 @@ Request body:
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`
Response `201`:
@@ -889,6 +897,40 @@ Possible errors:
- `409` if that owner email owns more than one receiving flock
- `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`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+761 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import flockPalTextArt from './assets/flockpal-text.png';
import defaultBirdPhoto from './assets/yoda-default.png';
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
import QRCode from 'qrcode';
@@ -24,6 +25,10 @@ type Bird = {
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;
@@ -219,6 +224,10 @@ type BirdFormState = {
motivators: string;
demotivators: string;
favoriteSnack: string;
vetClinicName: string;
vetClinicAddress: string;
vetAccountNumber: string;
vetDoctorName: string;
gender: BirdGender;
dateOfBirth: string;
gotchaDay: string;
@@ -229,6 +238,8 @@ type BirdFormState = {
publicProfileEnabled: boolean;
};
type VeterinaryInfoFormState = Pick<BirdFormState, 'vetClinicName' | 'vetClinicAddress' | 'vetAccountNumber' | 'vetDoctorName'>;
type BirdImportWeight = {
weightGrams: number;
recordedOn: string;
@@ -362,7 +373,7 @@ type WeightDropAlert = {
};
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit';
type DismissedAlertMap = Record<string, boolean>;
type PhotoCropState = {
@@ -627,6 +638,10 @@ const emptyBirdForm: BirdFormState = {
motivators: '',
demotivators: '',
favoriteSnack: '',
vetClinicName: '',
vetClinicAddress: '',
vetAccountNumber: '',
vetDoctorName: '',
gender: 'unknown',
dateOfBirth: '',
gotchaDay: '',
@@ -637,6 +652,13 @@ const emptyBirdForm: BirdFormState = {
publicProfileEnabled: false,
};
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
vetClinicName: '',
vetClinicAddress: '',
vetAccountNumber: '',
vetDoctorName: '',
};
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
memorializedOn: new Date().toISOString().slice(0, 10),
memorialNote: '',
@@ -758,6 +780,10 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
motivators: parseBirdProfileList(bird.motivators).join('\n'),
demotivators: parseBirdProfileList(bird.demotivators).join('\n'),
favoriteSnack: bird.favoriteSnack ?? '',
vetClinicName: bird.vetClinicName ?? '',
vetClinicAddress: bird.vetClinicAddress ?? '',
vetAccountNumber: bird.vetAccountNumber ?? '',
vetDoctorName: bird.vetDoctorName ?? '',
gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '',
@@ -768,6 +794,13 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
publicProfileEnabled: bird.publicProfileEnabled,
});
const toVeterinaryInfoForm = (bird: Bird): VeterinaryInfoFormState => ({
vetClinicName: bird.vetClinicName ?? '',
vetClinicAddress: bird.vetClinicAddress ?? '',
vetAccountNumber: bird.vetAccountNumber ?? '',
vetDoctorName: bird.vetDoctorName ?? '',
});
const formatDate = (value: string | null) => {
if (!value) {
return 'Not set';
@@ -811,6 +844,14 @@ const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
return '?';
};
const escapeReportHtml = (value: string | number | null | undefined) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatDateTime = (value: string | null) => {
if (!value) {
return 'Never';
@@ -1538,6 +1579,7 @@ function App() {
reason: '',
notes: '',
});
const [veterinaryInfoForm, setVeterinaryInfoForm] = useState<VeterinaryInfoFormState>(emptyVeterinaryInfoForm);
const [medicationForm, setMedicationForm] = useState({
name: '',
dosage: '',
@@ -1552,16 +1594,27 @@ function App() {
birdId: '',
destinationOwnerEmail: '',
});
const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({
code: '',
});
const [transferringBird, setTransferringBird] = useState(false);
const [acceptingTransferCode, setAcceptingTransferCode] = useState(false);
const [transferError, setTransferError] = useState('');
const [transferCodeError, setTransferCodeError] = useState('');
const [transferNotice, setTransferNotice] = useState<{
message: string;
previewUrl?: string | null;
} | null>(null);
const [transferCodeNotice, setTransferCodeNotice] = useState('');
const [adoptionTransferCodes, setAdoptionTransferCodes] = useState<Record<string, string>>({});
const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false);
const [adoptionReportError, setAdoptionReportError] = useState('');
const [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [editingVeterinaryInfo, setEditingVeterinaryInfo] = useState(false);
const [savingVeterinaryInfo, setSavingVeterinaryInfo] = useState(false);
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [editingMedicationId, setEditingMedicationId] = useState('');
const [deletingMedicationId, setDeletingMedicationId] = useState('');
@@ -1573,6 +1626,7 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId],
);
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
@@ -1602,6 +1656,15 @@ function App() {
setSelectedBirdTab('info');
}, [selectedBirdId]);
useEffect(() => {
if (selectedBird) {
setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird));
} else {
setVeterinaryInfoForm(emptyVeterinaryInfoForm);
}
setEditingVeterinaryInfo(false);
}, [selectedBird]);
const overviewWindowStartDate = useMemo(() => {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
@@ -2345,6 +2408,7 @@ function App() {
setVetVisits([]);
setMedications([]);
setMedicationAdministrations([]);
setVeterinaryInfoForm(emptyVeterinaryInfoForm);
return;
}
@@ -3190,6 +3254,49 @@ function App() {
}
};
const handleVeterinaryInfoSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird || savingVeterinaryInfo) {
return;
}
setError('');
setSavingVeterinaryInfo(true);
try {
const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...toBirdForm(selectedBird),
...veterinaryInfoForm,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save veterinary info.'));
}
const data = await readJsonSafely<{ bird?: Bird }>(response);
if (!data?.bird) {
throw new Error('Unable to save veterinary info.');
}
const savedBird = data.bird;
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird))));
setVeterinaryInfoForm(toVeterinaryInfoForm(savedBird));
setEditingVeterinaryInfo(false);
if (editingBirdId === savedBird.id) {
setBirdForm(toBirdForm(savedBird));
}
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save veterinary info.');
} finally {
setSavingVeterinaryInfo(false);
}
};
const handleWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -3861,6 +3968,388 @@ function App() {
}
};
const handleTransferCodeAcceptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (acceptingTransferCode) {
return;
}
const code = transferCodeAcceptForm.code.trim();
setError('');
setTransferCodeError('');
setTransferCodeNotice('');
setAcceptingTransferCode(true);
try {
const response = await apiFetch(`/bird-transfer-codes/${encodeURIComponent(code)}/accept`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to accept bird transfer code.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
sourceWorkspaceName?: string;
}>(response)) ?? {};
if (!data.bird) {
throw new Error('Unable to accept bird transfer code.');
}
setBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== data.bird!.id), data.bird!]));
setSelectedBirdId(data.bird.id);
setTransferCodeAcceptForm({ code: '' });
setTransferCodeNotice(`${data.bird.name} was transferred into ${workspace?.name ?? 'your active flock'}.`);
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Unable to accept bird transfer code.';
setTransferCodeError(message);
setError(message);
} finally {
setAcceptingTransferCode(false);
}
};
const handleCreateAdoptionTransferCode = async () => {
if (!selectedBird || creatingAdoptionReportCode) {
return null;
}
const existingCode = adoptionTransferCodes[selectedBird.id];
if (existingCode) {
return existingCode;
}
setAdoptionReportError('');
setCreatingAdoptionReportCode(true);
try {
const response = await apiFetch(`/birds/${selectedBird.id}/transfer-code`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create adoption transfer code.'));
}
const data =
(await readJsonSafely<{
transferCode?: {
code?: string;
bird?: Bird;
};
}>(response)) ?? {};
const code = data.transferCode?.code;
if (!code) {
throw new Error('Unable to create adoption transfer code.');
}
if (data.transferCode?.bird) {
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === data.transferCode!.bird!.id ? data.transferCode!.bird! : bird))));
}
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: code }));
return code;
} catch (codeError) {
const message = codeError instanceof Error ? codeError.message : 'Unable to create adoption transfer code.';
setAdoptionReportError(message);
setError(message);
return null;
} finally {
setCreatingAdoptionReportCode(false);
}
};
const openAdoptionReport = (transferCode: string, reportWindow = window.open('', '_blank'), printFriendly = false) => {
if (!selectedBird) {
return;
}
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const qr = createQrPath(transferCode);
const toReportAssetUrl = (value: string) =>
value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://') ? value : new URL(value, window.location.origin).toString();
const reportLogoUrl = toReportAssetUrl(flockPalLandingArt);
const reportWordmarkUrl = toReportAssetUrl(flockPalTextArt);
const reportPhotoUrl = toReportAssetUrl(selectedBird.photoDataUrl || defaultBirdPhoto);
const profileRows = [
['Name', selectedBird.name],
['Species', selectedBird.species],
['Band/tag ID', selectedBird.tagId || 'Not recorded'],
['Sex', getBirdGenderLabel(selectedBird)],
['Hatch day', formatDate(selectedBird.dateOfBirth)],
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
];
const vetRows = [
['Clinic name', selectedBird.vetClinicName || 'Not recorded'],
['Clinic address', selectedBird.vetClinicAddress || 'Not recorded'],
['Account #', selectedBird.vetAccountNumber || 'Not recorded'],
['Dr. name', selectedBird.vetDoctorName || 'Not recorded'],
];
const detailList = (label: string, value: string | null) => {
const entries = parseBirdProfileList(value);
return entries.length
? `<section><h3>${escapeReportHtml(label)}</h3><ul>${entries.map((entry) => `<li>${escapeReportHtml(entry)}</li>`).join('')}</ul></section>`
: `<section><h3>${escapeReportHtml(label)}</h3><p>Not recorded</p></section>`;
};
const weightRows = weights.length
? weights
.map(
(entry) =>
`<tr><td>${escapeReportHtml(formatDate(entry.recordedOn))}</td><td>${escapeReportHtml(formatWeight(entry.weightGrams))}</td><td>${escapeReportHtml(entry.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="3">No weights recorded.</td></tr>';
const vetVisitRows = vetVisits.length
? vetVisits
.map(
(visit) =>
`<tr><td>${escapeReportHtml(formatDate(visit.visitedOn))}</td><td>${escapeReportHtml(visit.clinicName)}</td><td>${escapeReportHtml(visit.reason)}</td><td>${escapeReportHtml(visit.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="4">No vet visits recorded.</td></tr>';
const noteRows = selectedBirdNotes.length
? selectedBirdNotes
.map(
(note) =>
`<article class="note"><strong>${escapeReportHtml(formatDateTime(note.updatedAt))}</strong><p>${escapeReportHtml(note.body)}</p></article>`,
)
.join('')
: '<p>No notes recorded.</p>';
const chartSvg =
selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length
? `<svg viewBox="0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}" role="img" aria-label="Weight graph">
${selectedBirdChart.yTicks
.map(
(tick) =>
`<line x1="${MEMBER_CHART_PADDING.left}" y1="${tick.y}" x2="${MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}" y2="${tick.y}" class="grid" />`,
)
.join('')}
${selectedBirdChart.historicalPath ? `<path d="${escapeReportHtml(selectedBirdChart.historicalPath)}" class="historical" />` : ''}
${selectedBirdChart.path ? `<path d="${escapeReportHtml(selectedBirdChart.path)}" class="current" />` : ''}
${selectedBirdChart.points
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="4" class="dot"><title>${escapeReportHtml(point.label)}</title></circle>`)
.join('')}
</svg>`
: '<p>No weight graph available yet.</p>';
const bodyBackground = printFriendly
? 'var(--paper)'
: `radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%)`;
const headerBackground = printFriendly
? '#fff'
: 'linear-gradient(135deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9))';
const panelBackground = printFriendly ? '#fff' : 'var(--panel)';
const backgroundOverlayCss = printFriendly
? ''
: `body::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='420' viewBox='0 0 360 420'%3E%3Cg fill='none' stroke-linecap='round' stroke-width='18' opacity='.5'%3E%3Cg stroke='%235bb3b7' transform='translate(54 42) rotate(-18)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3Cg stroke='%237eb773' transform='translate(204 78) rotate(28)'%3E%3Cpath d='M0 -38v76'/%3E%3Cpath d='M-32 -19l64 38'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(312 54) rotate(-38)'%3E%3Cpath d='M0 -28v56'/%3E%3Cpath d='M-24 -14l48 28'/%3E%3C/g%3E%3Cg stroke='%23898b93' transform='translate(118 172) rotate(42)'%3E%3Cpath d='M0 -30v60'/%3E%3Cpath d='M-26 -15l52 30'/%3E%3C/g%3E%3Cg stroke='%23b9c945' transform='translate(278 208) rotate(-12)'%3E%3Cpath d='M0 -36v72'/%3E%3Cpath d='M-31 -18l62 36'/%3E%3C/g%3E%3Cg stroke='%235bb3b7' transform='translate(52 326) rotate(22)'%3E%3Cpath d='M0 -26v52'/%3E%3Cpath d='M-22 -13l44 26'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(186 352) rotate(-48)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center top;
background-repeat: repeat;
background-size: 360px 420px;
content: "";
inset: 0;
opacity: 0.42;
pointer-events: none;
position: fixed;
z-index: -1;
}`;
reportWindow.document.write(`<!doctype html>
<html>
<head>
<title>FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)}</title>
<style>
:root {
--ink: #1f2a2a;
--muted: #5d5f59;
--paper: #fffdf9;
--panel: #fbf7ee;
--border: rgba(53, 129, 98, 0.28);
--red: #cb3a35;
--green: #238a5a;
--blue: #2769b3;
--gold: #f0b63f;
}
body {
background: ${bodyBackground};
color: var(--ink);
font-family: Inter, Arial, sans-serif;
line-height: 1.45;
margin: 32px;
min-height: 100vh;
position: relative;
}
${backgroundOverlayCss}
header {
background: ${headerBackground};
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14);
display: grid;
gap: 22px;
grid-template-columns: 210px 1fr 320px;
min-height: 228px;
padding: 18px;
}
h1, h2, h3, p { margin: 0; }
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; }
h2 {
border-bottom: 1px solid var(--border);
color: var(--green);
font-size: 19px;
margin: 28px 0 12px;
padding-bottom: 8px;
}
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; }
.muted { color: var(--muted); margin-top: 6px; }
.brand-logo {
align-self: center;
height: 210px;
justify-self: start;
object-fit: contain;
width: 210px;
}
.report-title {
align-self: center;
justify-self: center;
text-align: center;
}
.report-title .muted { margin-top: 8px; }
.profile-photo {
aspect-ratio: 1;
background: #fff;
border: 3px solid var(--paper);
border-radius: 18px;
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
height: 132px;
margin: 0 auto 12px;
object-fit: cover;
width: 132px;
}
.qr { align-self: center; justify-self: end; text-align: center; width: 320px; }
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
.qr-join-label {
color: var(--green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
line-height: 1;
margin-bottom: -28px;
position: relative;
text-transform: uppercase;
z-index: 1;
}
.qr-wordmark {
display: block;
height: 150px;
margin: -28px auto -12px;
object-fit: contain;
width: 340px;
}
.qr-note {
color: var(--blue);
font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.28;
margin-top: 8px;
}
.grid { stroke: rgba(53, 129, 98, 0.16); }
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
.note p { margin-top: 6px; white-space: pre-wrap; }
main { margin-top: 24px; }
@media print {
body { margin: 14mm; }
header { box-shadow: none; break-inside: avoid; }
button { display: none; }
}
</style>
</head>
<body>
<header>
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
<div class="report-title">
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
<h1>${escapeReportHtml(selectedBird.name)}</h1>
<p class="muted">Adoption Report</p>
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
</div>
<div class="qr">
<p class="qr-join-label">Join</p>
<img class="qr-wordmark" src="${escapeReportHtml(reportWordmarkUrl)}" alt="FlockPal">
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
</svg>
<p class="code">${escapeReportHtml(transferCode)}</p>
<p class="qr-note">Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.</p>
</div>
</header>
<main>
<h2>Flock Member Info</h2>
<section class="facts">
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
${detailList('Motivators', selectedBird.motivators)}
${detailList('Demotivators', selectedBird.demotivators)}
<h2>Weight Graph</h2>
${chartSvg}
<h2>Weight History</h2>
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
<h2>Veterinary Clinic Info</h2>
<section class="facts">
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
<h2>Vet Visit History</h2>
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
<h2>Notes</h2>
${noteRows}
</main>
</body>
</html>`);
reportWindow.document.close();
reportWindow.focus();
};
const handleOpenAdoptionReport = async (printFriendly = false) => {
const reportWindow = window.open('', '_blank');
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const code = selectedBirdAdoptionTransferCode || (await handleCreateAdoptionTransferCode());
if (code) {
openAdoptionReport(code, reportWindow, printFriendly);
} else {
reportWindow.close();
}
};
const saveWorkspaceSettings = async () => {
const response = await apiFetch('/workspace', authToken, {
method: 'PUT',
@@ -5017,7 +5506,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -5129,6 +5618,43 @@ function App() {
placeholder="Optional"
/>
</label>
<div className="settings-inline-header wide-field">
<p className="eyebrow">Veterinary</p>
<h3>Clinic account</h3>
</div>
<label>
Clinic name
<input
value={birdForm.vetClinicName}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicName: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Account #
<input
value={birdForm.vetAccountNumber}
onChange={(event) => setBirdForm({ ...birdForm, vetAccountNumber: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Dr. name
<input
value={birdForm.vetDoctorName}
onChange={(event) => setBirdForm({ ...birdForm, vetDoctorName: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Clinic address
<textarea
rows={2}
value={birdForm.vetClinicAddress}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicAddress: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Motivators
<div className="profile-list-fields">
@@ -5482,6 +6008,19 @@ function App() {
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('reports')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'reports'}
aria-label="Reports"
title="Reports"
>
<svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M280-280h80v-240h-80v240Zm160 0h80v-400h-80v400Zm160 0h80v-120h-80v120ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('audit')}
@@ -5843,6 +6382,105 @@ function App() {
{selectedBirdTab === 'vet' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Veterinary</p>
<h2>Clinic account</h2>
</div>
{!editingVeterinaryInfo ? (
<button
className="profile-icon-button"
onClick={() => {
setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird));
setEditingVeterinaryInfo(true);
}}
type="button"
aria-label={`Edit veterinary info for ${selectedBird.name}`}
title="Edit veterinary info"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 20h4l10.5-10.5-4-4L4 16v4Z" />
<path d="m13.5 6.5 4 4" />
<path d="M15 5l1.5-1.5a2.1 2.1 0 0 1 3 3L18 8" />
</svg>
</button>
) : null}
</div>
{editingVeterinaryInfo ? (
<form className="form-panel inline-form care-entry-form" onSubmit={handleVeterinaryInfoSubmit}>
<label>
Clinic name
<input
value={veterinaryInfoForm.vetClinicName}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetClinicName: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Account #
<input
value={veterinaryInfoForm.vetAccountNumber}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetAccountNumber: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Dr. name
<input
value={veterinaryInfoForm.vetDoctorName}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetDoctorName: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Clinic address
<textarea
rows={2}
value={veterinaryInfoForm.vetClinicAddress}
onChange={(event) => setVeterinaryInfoForm({ ...veterinaryInfoForm, vetClinicAddress: event.target.value })}
placeholder="Optional"
/>
</label>
<div className="button-row care-form-actions">
<button className="primary-button" type="submit" disabled={savingVeterinaryInfo}>
{savingVeterinaryInfo ? 'Saving veterinary info...' : 'Save veterinary info'}
</button>
<button
className="secondary-button"
onClick={() => {
setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird));
setEditingVeterinaryInfo(false);
}}
type="button"
disabled={savingVeterinaryInfo}
>
Cancel
</button>
</div>
</form>
) : (
<div className="detail-grid">
<article className="detail-card">
<span>Clinic name</span>
<strong>{selectedBird.vetClinicName || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Account #</span>
<strong>{selectedBird.vetAccountNumber || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Dr. name</span>
<strong>{selectedBird.vetDoctorName || 'Not recorded'}</strong>
</article>
<article className="detail-card wide-field">
<span>Clinic address</span>
<strong>{selectedBird.vetClinicAddress || 'Not recorded'}</strong>
</article>
</div>
)}
</section>
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Vet visits</p>
@@ -5991,6 +6629,42 @@ function App() {
</div>
) : null}
{selectedBirdTab === 'reports' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Reports</p>
<h2>Adoption report</h2>
</div>
</div>
<p className="muted">
Create a print-ready adoption report with profile details, weight history, veterinary clinic info, vet visits,
notes, and a transfer code for accepting {selectedBird.name} into another active flock.
</p>
<div className="detail-grid">
<article className="detail-card">
<span>Transfer code</span>
<strong>{selectedBirdAdoptionTransferCode || 'Not generated'}</strong>
</article>
</div>
<div className="button-row">
<button className="primary-button" onClick={() => handleOpenAdoptionReport(false)} type="button" disabled={creatingAdoptionReportCode}>
{creatingAdoptionReportCode ? 'Preparing report...' : 'Open adoption report'}
</button>
<button className="secondary-button" onClick={() => handleOpenAdoptionReport(true)} type="button" disabled={creatingAdoptionReportCode}>
Print-friendly report
</button>
</div>
{adoptionReportError ? (
<p className="error-banner" role="alert">
{adoptionReportError}
</p>
) : null}
</section>
</div>
) : null}
{selectedBirdTab === 'audit' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
@@ -6755,7 +7429,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -6894,6 +7568,43 @@ function App() {
placeholder="Optional"
/>
</label>
<div className="settings-inline-header wide-field">
<p className="eyebrow">Veterinary</p>
<h3>Clinic account</h3>
</div>
<label>
Clinic name
<input
value={birdForm.vetClinicName}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicName: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Account #
<input
value={birdForm.vetAccountNumber}
onChange={(event) => setBirdForm({ ...birdForm, vetAccountNumber: event.target.value })}
placeholder="Optional"
/>
</label>
<label>
Dr. name
<input
value={birdForm.vetDoctorName}
onChange={(event) => setBirdForm({ ...birdForm, vetDoctorName: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Clinic address
<textarea
rows={2}
value={birdForm.vetClinicAddress}
onChange={(event) => setBirdForm({ ...birdForm, vetClinicAddress: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Motivators
<div className="profile-list-fields">
@@ -7390,6 +8101,12 @@ function App() {
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
vet visits attached while changing which flock owns it.
</p>
<div className="settings-nested-stack">
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Owner transfer</p>
<h3>Send to a known flock owner</h3>
</div>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label>
Bird to move
@@ -7440,6 +8157,47 @@ function App() {
</article>
) : null}
</form>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Transfer code</p>
<h3>Accept a bird into this flock</h3>
</div>
<p className="muted">
Enter a transfer code from an adoption or handoff report. The bird will move into your active flock automatically.
</p>
<form className="form-panel" onSubmit={handleTransferCodeAcceptSubmit}>
<label>
Transfer code
<input
value={transferCodeAcceptForm.code}
onChange={(event) => {
setTransferCodeAcceptForm({ code: event.target.value });
setTransferCodeError('');
setTransferCodeNotice('');
}}
placeholder="Paste transfer code"
required
/>
</label>
<button className="primary-button" type="submit" disabled={acceptingTransferCode}>
{acceptingTransferCode ? 'Accepting transfer...' : 'Accept bird transfer'}
</button>
{transferCodeError ? (
<p className="error-banner" role="alert">
{transferCodeError}
</p>
) : null}
{transferCodeNotice ? (
<article className="summary-card" role="status">
<strong>Transfer accepted</strong>
<span>{transferCodeNotice}</span>
</article>
) : null}
</form>
</section>
</div>
</>
) : null}
</article>
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 .note-tab-icon,
.bird-detail-tab .report-tab-icon,
.bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon {
width: 24px;
@@ -1706,6 +1707,44 @@ label {
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 {
border: 0;
border-radius: 18px;