working on timeline locations
Deploy / deploy-dev (push) Successful in 2m21s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-06-28 22:57:22 -04:00
parent a988d9662b
commit 9ddd85b5c4
8 changed files with 430 additions and 41 deletions
+78 -5
View File
@@ -286,6 +286,19 @@ const birdProfileListSchema = z
.optional()
.or(z.literal(''));
const verifiedLocationDetailsSchema = z
.object({
city: z.string().trim().max(120).optional().or(z.literal('')),
region: z.string().trim().max(120).optional().or(z.literal('')),
country: z.string().trim().max(120).optional().or(z.literal('')),
countryCode: z.string().trim().max(2).optional().or(z.literal('')),
latitude: z.coerce.number().min(-90).max(90).optional().nullable().or(z.literal('')),
longitude: z.coerce.number().min(-180).max(180).optional().nullable().or(z.literal('')),
precision: z.enum(['city', 'region', 'country']).optional(),
})
.optional()
.nullable();
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().max(80).optional().or(z.literal('')),
@@ -294,6 +307,7 @@ const birdSchema = z.object({
demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
locationLabel: z.string().trim().max(160).optional().or(z.literal('')),
locationDetails: verifiedLocationDetailsSchema,
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('')),
@@ -323,13 +337,56 @@ const birdTimelineEventSchema = z
eventType: z.enum(['location_updated', 'owner_changed', 'manual_note']),
eventDate: dateStringSchema.optional().or(z.literal('')),
locationLabel: z.string().trim().max(160).optional().or(z.literal('')),
locationDetails: verifiedLocationDetailsSchema,
note: z.string().trim().max(500).optional().or(z.literal('')),
})
.refine(
(value) => value.eventType === 'owner_changed' || Boolean(value.locationLabel?.trim() || value.note?.trim()),
(value) =>
value.eventType === 'owner_changed' ||
Boolean(
value.locationLabel?.trim() ||
value.note?.trim() ||
value.locationDetails?.city?.trim() ||
value.locationDetails?.region?.trim() ||
value.locationDetails?.country?.trim(),
),
'Add a location or note for this timeline item.',
);
type VerifiedLocationDetailsInput = z.infer<typeof verifiedLocationDetailsSchema>;
const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) => {
if (!value) {
return null;
}
const city = value.city?.trim() || null;
const region = value.region?.trim() || null;
const country = value.country?.trim() || null;
const countryCode = value.countryCode?.trim().toUpperCase() || null;
const latitude = typeof value.latitude === 'number' ? Number(value.latitude.toFixed(4)) : null;
const longitude = typeof value.longitude === 'number' ? Number(value.longitude.toFixed(4)) : null;
const precision = value.precision ?? (city ? 'city' : region ? 'region' : country ? 'country' : null);
if (!city && !region && !country && !countryCode && latitude === null && longitude === null) {
return null;
}
return {
city,
region,
country,
countryCode,
latitude,
longitude,
precision,
verifiedAt: new Date().toISOString(),
};
};
const formatVerifiedLocationLabel = (details: ReturnType<typeof normalizeVerifiedLocationDetails>) =>
details ? [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema,
@@ -707,6 +764,7 @@ const normalizeBird = (row: BirdRow) => ({
demotivators: row.demotivators,
favoriteSnack: row.favorite_snack,
locationLabel: row.location_label,
locationDetails: row.location_details,
vetClinicName: row.vet_clinic_name,
vetClinicAddress: row.vet_clinic_address,
vetAccountNumber: row.vet_account_number,
@@ -825,6 +883,7 @@ const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({
fromOwnerEmail: row.from_owner_email,
toOwnerEmail: row.to_owner_email,
locationLabel: row.location_label,
locationDetails: row.location_details,
note: row.note,
eventDate: row.event_date,
createdByUserId: row.created_by_user_id,
@@ -2367,6 +2426,7 @@ const writeBirdTimelineEvent = async ({
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
@@ -2376,6 +2436,7 @@ const writeBirdTimelineEvent = async ({
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
@@ -2387,6 +2448,7 @@ const writeBirdTimelineEvent = async ({
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
@@ -3644,11 +3706,13 @@ app.post(
return;
}
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
const event = await createBirdTimelineEvent({
birdId: bird.id,
eventType: parsed.data.eventType as BirdTimelineEventType,
toWorkspaceId: req.auth!.workspace.id,
locationLabel: emptyToNull(parsed.data.locationLabel),
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
locationDetails,
note: emptyToNull(parsed.data.note),
eventDate: emptyToNull(parsed.data.eventDate),
createdByUserId: req.auth!.user.id,
@@ -3742,6 +3806,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
workspaceId: req.auth!.workspace.id,
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
});
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
const bird = await createBird({
birdId,
@@ -3752,7 +3817,8 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: emptyToNull(parsed.data.locationLabel),
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
locationDetails,
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
@@ -3781,6 +3847,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
eventType: 'profile_created',
toWorkspaceId: req.auth!.workspace.id,
locationLabel: bird!.location_label,
locationDetails: bird!.location_details,
createdByUserId: req.auth!.user.id,
});
res.status(201).json({ bird: normalizeBird(bird!) });
@@ -4109,6 +4176,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
});
uploadedObjectKeyToCleanup =
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
const bird = await updateBird({
birdId: req.params.birdId,
workspaceId: req.auth!.workspace.id,
@@ -4118,7 +4186,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: emptyToNull(parsed.data.locationLabel),
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
locationDetails,
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
@@ -4148,12 +4217,16 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
previousName: existingBird.name,
species: bird.species,
});
if ((existingBird.location_label ?? '') !== (bird.location_label ?? '')) {
if (
(existingBird.location_label ?? '') !== (bird.location_label ?? '') ||
JSON.stringify(existingBird.location_details ?? null) !== JSON.stringify(bird.location_details ?? null)
) {
await writeBirdTimelineEvent({
birdId: bird.id,
eventType: 'location_updated',
toWorkspaceId: req.auth!.workspace.id,
locationLabel: bird.location_label,
locationDetails: bird.location_details,
createdByUserId: req.auth!.user.id,
});
}
+4
View File
@@ -250,6 +250,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
location_label VARCHAR(160),
location_details JSONB,
vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120),
@@ -279,6 +280,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_details JSONB,
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),
@@ -415,6 +417,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
from_owner_email VARCHAR(255),
to_owner_email VARCHAR(255),
location_label VARCHAR(160),
location_details JSONB,
note TEXT,
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
@@ -423,6 +426,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE bird_timeline_events
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
@@ -222,6 +222,7 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
from_owner_email: 'sender@example.com',
to_owner_email: 'receiver@example.com',
location_label: 'Receiving Flock',
location_details: null,
created_by_user_id: 'user-1',
created_at: '2026-04-15T00:00:00.000Z',
},
@@ -249,5 +250,6 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
null,
null,
'user-1',
null,
]);
});
+25 -13
View File
@@ -29,6 +29,7 @@ const birdSelectFields = `
birds.demotivators,
birds.favorite_snack,
birds.location_label,
birds.location_details,
birds.vet_clinic_name,
birds.vet_clinic_address,
birds.vet_account_number,
@@ -171,6 +172,7 @@ export const createBirdTimelineEvent = async ({
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
@@ -180,6 +182,7 @@ export const createBirdTimelineEvent = async ({
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
@@ -202,10 +205,11 @@ export const createBirdTimelineEvent = async ({
location_label,
note,
event_date,
created_by_user_id
created_by_user_id,
location_details
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, $6, $5), $10, COALESCE($11::date, CURRENT_DATE), $12)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, note, event_date::text, created_by_user_id, created_at`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text, $6::text, $5::text), $10, COALESCE($11::date, CURRENT_DATE), $12, $13)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
[
birdId,
eventType,
@@ -219,6 +223,7 @@ export const createBirdTimelineEvent = async ({
note ?? null,
eventDate ?? null,
createdByUserId ?? null,
locationDetails ?? null,
],
);
@@ -227,7 +232,7 @@ export const createBirdTimelineEvent = async ({
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdTimelineEventRow>(
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, note, event_date::text, created_by_user_id, created_at
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
FROM bird_timeline_events
WHERE bird_id = $1
AND EXISTS (
@@ -477,6 +482,7 @@ export const createBird = async ({
demotivators,
favoriteSnack,
locationLabel = null,
locationDetails = null,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
@@ -503,6 +509,7 @@ export const createBird = async ({
demotivators: string | null;
favoriteSnack: string | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName?: string | null;
vetClinicAddress?: string | null;
vetAccountNumber?: string | null;
@@ -521,9 +528,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, location_label, 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, $25)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, 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`,
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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, $25, $26)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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,
@@ -534,6 +541,7 @@ export const createBird = async ({
demotivators,
favoriteSnack,
locationLabel,
locationDetails,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -566,6 +574,7 @@ export const updateBird = async ({
demotivators,
favoriteSnack,
locationLabel,
locationDetails,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -592,6 +601,7 @@ export const updateBird = async ({
demotivators: string | null;
favoriteSnack: string | null;
locationLabel: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
@@ -633,11 +643,12 @@ export const updateBird = async ({
notify_on_dob = $21,
notify_on_gotcha_day = $22,
public_profile_code = $23,
public_profile_enabled = $24
public_profile_enabled = $24,
location_details = $25
WHERE id = $1
AND workspace_id = $25
AND workspace_id = $26
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, 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,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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
@@ -677,6 +688,7 @@ export const updateBird = async ({
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
locationDetails ?? null,
workspaceId,
],
);
@@ -706,7 +718,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, motivators, demotivators, favorite_snack, location_label, 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,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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
@@ -742,7 +754,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, motivators, demotivators, favorite_snack, location_label, 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,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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
@@ -782,7 +794,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, motivators, demotivators, favorite_snack, location_label, 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,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, 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
+2
View File
@@ -131,6 +131,7 @@ export type BirdRow = {
demotivators: string | null;
favorite_snack: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
vet_clinic_name: string | null;
vet_clinic_address: string | null;
vet_account_number: string | null;
@@ -217,6 +218,7 @@ export type BirdTimelineEventRow = {
from_owner_email: string | null;
to_owner_email: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
note: string | null;
event_date: string;
created_by_user_id: string | null;