Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59c6b19ad6 | |||
| aa1a4cf6ff | |||
| 5f0fad3cbb | |||
| 545fae59b2 | |||
| d748d2db21 | |||
| 095c91e56d |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
+137
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+810
-52
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user