39 Commits
Author SHA1 Message Date
blaisadmin 3ba6410972 Merge branch 'dev'
Deploy / deploy-dev (push) Skipped
Deploy / deploy-prod (push) Successful in 3m3s
# Conflicts:
#	backend/src/app.ts
#	backend/src/db/schema.ts
#	backend/src/queues/adoptionReportQueue.ts
#	backend/src/reports/adoptionReport.ts
#	backend/src/reports/adoptionReportJob.ts
#	backend/src/repositories/birdRepository.ts
#	backend/src/types.ts
#	frontend/src/App.tsx
#	frontend/src/index.css
2026-07-21 22:25:29 -04:00
blaisadmin a6eac3009d changed portrait shape
Deploy / deploy-dev (push) Successful in 2m23s
Deploy / deploy-prod (push) Skipped
2026-07-21 22:14:27 -04:00
blaisadmin 737a84a8b8 Added graph photos
Deploy / deploy-prod (push) Skipped
Deploy / deploy-dev (push) Successful in 3m21s
2026-07-21 21:36:51 -04:00
Corey Blais 18fd76dc1f another fix of timeline fonts
Deploy / deploy-dev (push) Successful in 2m13s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 21:05:23 -04:00
Corey Blais f627157a14 fixing timeline fonts in dev
Deploy / deploy-dev (push) Successful in 2m14s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 18:16:43 -04:00
Corey Blais 35bd87b8b5 Updated timeline
Deploy / deploy-dev (push) Successful in 2m20s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 17:52:40 -04:00
blaisadmin 14bc1c30a0 Merge origin/dev into dev
Deploy / deploy-dev (push) Successful in 3m48s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 23:29:31 -04:00
blaisadmin d03672fcdd Fixed Timeline 2026-06-30 23:25:07 -04:00
Corey Blais 46e07336ef Fixing dev timeline icons
Deploy / deploy-dev (push) Successful in 2m12s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 18:25:30 -04:00
Corey Blais bcaa8c4464 Fixing dev timeline icons
Deploy / deploy-dev (push) Successful in 2m15s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 18:05:59 -04:00
Corey Blais 84d850a1ba Updated timeline
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 17:05:26 -04:00
Corey Blais 0dfacc0d17 Fixed timeline icon
Deploy / deploy-dev (push) Successful in 2m9s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 12:16:31 -04:00
blaisadmin 7ef20ab0fb Added mapbox integration
Deploy / deploy-dev (push) Successful in 2m33s
Deploy / deploy-prod (push) Has been skipped
2026-06-29 19:50:08 -04:00
blaisadmin 9ddd85b5c4 working on timeline locations
Deploy / deploy-dev (push) Successful in 2m21s
Deploy / deploy-prod (push) Has been skipped
2026-06-28 22:57:22 -04:00
Corey Blais a988d9662b Added timeline feature
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-28 12:30:36 -04:00
blaisadmin 56068e02a3 Additional Genders
Deploy / deploy-dev (push) Successful in 3m17s
Deploy / deploy-prod (push) Has been skipped
2026-06-17 21:59:09 -04:00
Corey Blais 1140be8f32 weight edit fixes
Deploy / deploy-dev (push) Successful in 3m1s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 15:28:01 -04:00
Corey Blais 4d3ab0b143 Updated weight edit
Deploy / deploy-dev (push) Successful in 2m1s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 10:33:34 -04:00
Corey Blais 1e98d55cb5 trimmed weight edit
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 10:00:50 -04:00
Corey Blais 454adc6f5e adding weight edits
Deploy / deploy-dev (push) Successful in 3m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 09:31:16 -04:00
blaisadmin ae8c4326b5 dev login fixes
Deploy / deploy-dev (push) Successful in 1m55s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 22:12:19 -04:00
blaisadmin 480bbe8fc7 Adding promoting to owner
Deploy / deploy-dev (push) Successful in 2m26s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 21:15:55 -04:00
blaisadmin bb589e3489 Adjusting role actions
Deploy / deploy-dev (push) Successful in 2m29s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 21:09:31 -04:00
Corey Blais c3bec15c63 Medication reminder and pdr worker
Deploy / deploy-dev (push) Successful in 2m39s
Deploy / deploy-prod (push) Has been skipped
2026-06-03 15:21:21 -04:00
Corey Blais 979a17132d Fix adoption report photos and section order
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:26:51 -04:00
Corey Blais cadbdc2a7f Fix adoption report QR and chart layout
Deploy / deploy-dev (push) Successful in 1m40s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:19:01 -04:00
Corey Blais 8e2f789e9b Refine adoption report header layout
Deploy / deploy-dev (push) Successful in 1m45s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:10:13 -04:00
Corey Blais 7e2d06c50b Generate adoption reports as PDFs
Deploy / deploy-dev (push) Successful in 2m24s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:49:31 -04:00
Corey Blais c2d518f864 Use fixed screen size for adoption report sheet
Deploy / deploy-dev (push) Successful in 1m32s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:31:33 -04:00
Corey Blais c3297b5915 Wrap adoption report in letter sheet
Deploy / deploy-dev (push) Successful in 1m37s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:24:32 -04:00
Corey Blais 41dda33310 Tighten adoption report page scale
Deploy / deploy-dev (push) Successful in 1m40s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:17:21 -04:00
Corey Blais c98a5a2863 Size adoption report for letter paper
Deploy / deploy-dev (push) Successful in 1m38s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:10:55 -04:00
Corey Blais 6c9017c3dc Stabilize adoption report layout
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 16:33:26 -04:00
Corey Blais 6b11a73579 fixing analytics icon pt2
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:28:34 -04:00
Corey Blais 1f26255ebd fixing analytics icon
Deploy / deploy-dev (push) Successful in 1m45s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:22:14 -04:00
Corey Blais 14cdfe603d Updated adoption report
Deploy / deploy-dev (push) Successful in 2m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:13:01 -04:00
Corey Blais d5bb87910e Added adoption report and transfer code
Deploy / deploy-dev (push) Successful in 2m31s
Deploy / deploy-prod (push) Has been skipped
2026-06-01 18:57:53 -04:00
Corey Blais 3053e3bef5 Added vet info
Deploy / deploy-dev (push) Successful in 3m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-01 14:59:03 -04:00
blaisadmin 6ade13a8be Add flock member notes and audit tabs
Deploy / deploy-dev (push) Successful in 2m27s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 22:30:35 -04:00
19 changed files with 2150 additions and 992 deletions
+1
View File
@@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000 BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api VITE_API_BASE_URL=http://localhost:5000/api
MAPBOX_ACCESS_TOKEN=
NODE_ENV=development NODE_ENV=development
TRUST_PROXY= TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online ADMIN_EMAILS=corey@blaishome.online
+1
View File
@@ -103,6 +103,7 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
- `FRONTEND_URL` - `FRONTEND_URL`
- `BACKEND_URL` - `BACKEND_URL`
- `VITE_API_BASE_URL` - `VITE_API_BASE_URL`
- `MAPBOX_ACCESS_TOKEN`
- `REDIS_URL` - `REDIS_URL`
- `IMAGE_STORAGE_PROVIDER` - `IMAGE_STORAGE_PROVIDER`
- `S3_ENDPOINT` - `S3_ENDPOINT`
+482 -197
View File
@@ -12,9 +12,8 @@ import nodemailer, { type SendMailOptions } from 'nodemailer';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { db } from './db/client.js';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { adoptionReportQueueEvents, enqueueAdoptionReportJob, getAdoptionReportQueueCounts } from './queues/adoptionReportQueue.js'; import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js';
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js'; import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js'; import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
import { import {
@@ -38,6 +37,7 @@ import {
completePendingBirdTransfersForOwner, completePendingBirdTransfersForOwner,
createBird, createBird,
createBirdMilestoneReminderDelivery, createBirdMilestoneReminderDelivery,
createBirdTimelineEvent,
createMedicationReminderDelivery, createMedicationReminderDelivery,
createBirdTransferCode, createBirdTransferCode,
createMedicationForBird, createMedicationForBird,
@@ -51,8 +51,8 @@ import {
getBirdById, getBirdById,
getBirdByPublicProfileCode, getBirdByPublicProfileCode,
getOpenBirdTransferCode, getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listBirds, listBirds,
listBirdTimelineEvents,
listDueBirdMilestoneReminders, listDueBirdMilestoneReminders,
listDueMedicationReminders, listDueMedicationReminders,
listMemorializedBirds, listMemorializedBirds,
@@ -98,7 +98,6 @@ import {
getMembershipForUser, getMembershipForUser,
getNextWorkspaceId, getNextWorkspaceId,
getWorkspaceById, getWorkspaceById,
getWorkspaceBirdCount,
getWorkspaceTotalBirdCount, getWorkspaceTotalBirdCount,
listOwnedWorkspacesByOwnerEmail, listOwnedWorkspacesByOwnerEmail,
listRescueWorkspacesForAdmin, listRescueWorkspacesForAdmin,
@@ -114,14 +113,16 @@ import {
upsertWorkspaceMember, upsertWorkspaceMember,
} from './repositories/workspaceRepository.js'; } from './repositories/workspaceRepository.js';
import type { import type {
AuthContext, AuthContext,
AuditLogEntryRow, AuditLogEntryRow,
BillingInterval, BillingInterval,
BillingPlan, BillingPlan,
BirdGender, BirdGender,
BirdMilestoneReminderCandidateRow, BirdMilestoneReminderCandidateRow,
BirdRow, BirdRow,
FlockNoteRow, BirdTimelineEventType,
BirdTimelineEventRow,
FlockNoteRow,
IntegrationTokenRow, IntegrationTokenRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
@@ -270,6 +271,26 @@ const birdProfileListSchema = z
.optional() .optional()
.or(z.literal('')); .or(z.literal(''));
const verifiedLocationDetailsSchema = z
.object({
label: z.string().trim().max(220).optional().or(z.literal('')),
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(),
provider: z.literal('mapbox').optional().nullable(),
providerPlaceId: z.string().trim().max(220).optional().or(z.literal('')),
})
.optional()
.nullable();
const locationSearchSchema = z.object({
q: z.string().trim().min(3).max(120),
});
const birdSchema = z.object({ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
tagId: z.string().trim().max(80).optional().or(z.literal('')), tagId: z.string().trim().max(80).optional().or(z.literal('')),
@@ -277,11 +298,14 @@ const birdSchema = z.object({
motivators: birdProfileListSchema, motivators: birdProfileListSchema,
demotivators: birdProfileListSchema, demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), 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('')), vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')), vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')), vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')), vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(), gender: birdGenderSchema.optional(),
hatchDay: dateStringSchema.optional().or(z.literal('')),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')),
chartColor: chartColorSchema.optional(), chartColor: chartColorSchema.optional(),
@@ -301,6 +325,146 @@ const memorialReminderPreferenceSchema = z.object({
notifyOnMemorialDay: z.boolean(), notifyOnMemorialDay: z.boolean(),
}); });
const birdTimelineEventSchema = z
.object({
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.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);
const label = value.label?.trim() || [city, region, country].filter(Boolean).join(', ') || null;
const provider = value.provider ?? null;
const providerPlaceId = value.providerPlaceId?.trim() || null;
if (!label && !city && !region && !country && !countryCode && latitude === null && longitude === null) {
return null;
}
return {
label,
city,
region,
country,
countryCode,
latitude,
longitude,
precision,
provider,
providerPlaceId,
verifiedAt: new Date().toISOString(),
};
};
const formatVerifiedLocationLabel = (details: ReturnType<typeof normalizeVerifiedLocationDetails>) =>
details ? details.label || [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
type VerifiedLocationSearchResult = NonNullable<ReturnType<typeof normalizeVerifiedLocationDetails>>;
type MapboxGeocodeFeature = {
id?: string;
geometry?: {
coordinates?: [number, number];
};
properties?: {
mapbox_id?: string;
feature_type?: string;
name?: string;
full_address?: string;
place_formatted?: string;
coordinates?: {
longitude?: number;
latitude?: number;
};
context?: {
place?: { name?: string };
locality?: { name?: string };
region?: { name?: string; region_code?: string; region_code_full?: string };
country?: { name?: string; country_code?: string };
};
};
};
type MapboxGeocodeResponse = {
features?: MapboxGeocodeFeature[];
message?: string;
};
const mapboxAccessToken = process.env.MAPBOX_ACCESS_TOKEN?.trim() ?? '';
const allowedMapboxLocationTypes = new Set(['place', 'locality', 'region', 'country']);
const mapboxLocationCache = new Map<string, { expiresAt: number; results: VerifiedLocationSearchResult[] }>();
const mapboxLocationCacheTtlMs = 24 * 60 * 60 * 1000;
const getMapboxContextName = (feature: MapboxGeocodeFeature, key: 'place' | 'locality' | 'region' | 'country') =>
feature.properties?.context?.[key]?.name?.trim() || null;
const normalizeMapboxLocationFeature = (feature: MapboxGeocodeFeature): VerifiedLocationSearchResult | null => {
const properties = feature.properties;
const featureType = properties?.feature_type;
if (!featureType || !allowedMapboxLocationTypes.has(featureType)) {
return null;
}
const name = properties.name?.trim() || null;
const contextPlace = getMapboxContextName(feature, 'place');
const contextLocality = getMapboxContextName(feature, 'locality');
const contextRegion = getMapboxContextName(feature, 'region');
const contextCountry = getMapboxContextName(feature, 'country');
const city = featureType === 'place' || featureType === 'locality' ? name : contextPlace || contextLocality;
const region = featureType === 'region' ? name : contextRegion;
const country = featureType === 'country' ? name : contextCountry;
const countryCode = properties.context?.country?.country_code?.trim().toUpperCase() || null;
const longitude = properties.coordinates?.longitude ?? feature.geometry?.coordinates?.[0] ?? null;
const latitude = properties.coordinates?.latitude ?? feature.geometry?.coordinates?.[1] ?? null;
const label = [city, region, country].filter(Boolean).join(', ') || properties.full_address || properties.place_formatted || name || null;
const precision = featureType === 'country' ? 'country' : featureType === 'region' ? 'region' : 'city';
if (!label || typeof latitude !== 'number' || typeof longitude !== 'number') {
return null;
}
return normalizeVerifiedLocationDetails({
label,
city: city ?? '',
region: region ?? '',
country: country ?? '',
countryCode: countryCode ?? '',
latitude,
longitude,
precision,
provider: 'mapbox',
providerPlaceId: properties.mapbox_id || feature.id || '',
});
};
const weightSchema = z.object({ const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000), weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema, recordedOn: dateStringSchema,
@@ -637,11 +801,14 @@ const normalizeBird = (row: BirdRow) => ({
motivators: row.motivators, motivators: row.motivators,
demotivators: row.demotivators, demotivators: row.demotivators,
favoriteSnack: row.favorite_snack, favoriteSnack: row.favorite_snack,
locationLabel: row.location_label,
locationDetails: row.location_details,
vetClinicName: row.vet_clinic_name, vetClinicName: row.vet_clinic_name,
vetClinicAddress: row.vet_clinic_address, vetClinicAddress: row.vet_clinic_address,
vetAccountNumber: row.vet_account_number, vetAccountNumber: row.vet_account_number,
vetDoctorName: row.vet_doctor_name, vetDoctorName: row.vet_doctor_name,
gender: row.gender, gender: row.gender,
hatchDay: row.date_of_birth,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day, gotchaDay: row.gotcha_day,
chartColor: row.chart_color, chartColor: row.chart_color,
@@ -664,39 +831,13 @@ const normalizeBird = (row: BirdRow) => ({
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url'); const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
const ensureOpenBirdTransferCode = async (birdId: string, sourceWorkspaceId: number, requestedByUserId: string) => {
const existingTransferCode = await getOpenBirdTransferCodeForBird(birdId, sourceWorkspaceId);
if (existingTransferCode) {
return existingTransferCode;
}
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await createBirdTransferCode({
code: createBirdTransferCodeValue(),
birdId,
sourceWorkspaceId,
requestedByUserId,
});
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
continue;
}
throw error;
}
}
return null;
};
const normalizePublicBirdProfile = (row: BirdRow) => ({ const normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id, id: row.id,
workspaceId: row.workspace_id, workspaceId: row.workspace_id,
name: row.name, name: row.name,
favoriteSnack: row.favorite_snack, favoriteSnack: row.favorite_snack,
gender: row.gender, gender: row.gender,
hatchDay: row.date_of_birth,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
photoDataUrl: getBirdPhotoUrl(row), photoDataUrl: getBirdPhotoUrl(row),
}); });
@@ -771,6 +912,24 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
createdAt: row.created_at, createdAt: row.created_at,
}); });
const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({
id: row.id,
birdId: row.bird_id,
eventType: row.event_type,
fromWorkspaceId: row.from_workspace_id,
toWorkspaceId: row.to_workspace_id,
fromWorkspaceName: row.from_workspace_name,
toWorkspaceName: row.to_workspace_name,
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,
createdAt: row.created_at,
});
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
@@ -847,6 +1006,13 @@ const lostBirdReportLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many found bird reports. Please try again later.' }, message: { error: 'Too many found bird reports. Please try again later.' },
}); });
const locationSearchLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 40,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many location searches. Please try again later.' },
});
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => { app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
if (!stripeWebhookSecret) { if (!stripeWebhookSecret) {
res.status(503).json({ error: 'Stripe webhook is not configured.' }); res.status(503).json({ error: 'Stripe webhook is not configured.' });
@@ -993,26 +1159,6 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing'; return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
}; };
const getBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return null;
}
if (billingPlan === 'household_basic') {
return 4;
}
if (billingPlan === 'household_plus') {
return 10;
}
if (billingPlan === 'household_macaw') {
return 16;
}
return null;
};
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => { const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') { if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
return status; return status;
@@ -1403,6 +1549,37 @@ const deleteBirdPhotoObjectIfNeeded = async (objectKey: string | null) => {
} }
}; };
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
if (!bird.photo_object_key) {
return null;
}
const s3Config = getS3ImageStorageConfig();
if (!s3Config) {
return null;
}
const signedUrl = getSignedS3ObjectUrl({
config: s3Config,
objectKey: bird.photo_object_key,
expiresInSeconds: 5 * 60,
});
const imageResponse = await fetch(signedUrl);
if (!imageResponse.ok) {
return null;
}
const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || '';
if (!/^image\/(?:png|jpe?g)$/i.test(contentType)) {
return null;
}
return Buffer.from(await imageResponse.arrayBuffer());
};
const getDefaultBirdPhotoAttachment = () => { const getDefaultBirdPhotoAttachment = () => {
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png'); const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
@@ -2267,59 +2444,6 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
return false; return false;
}; };
type HealthCheckResult = {
ok: boolean;
latencyMs?: number;
error?: string;
};
const withHealthTimeout = async <T,>(operation: Promise<T>, timeoutMs = 2_000): Promise<T> => {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error('Health check timed out')), timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
const checkPostgresHealth = async (): Promise<HealthCheckResult> => {
const startedAt = Date.now();
try {
await withHealthTimeout(db.query('SELECT 1'));
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : 'Postgres health check failed',
};
}
};
const checkRedisHealth = async (): Promise<HealthCheckResult> => {
const startedAt = Date.now();
try {
await withHealthTimeout(getBirdMilestoneReminderQueueCounts());
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : 'Redis health check failed',
};
}
};
const writeAuditLog = async ( const writeAuditLog = async (
auth: AuthContext, auth: AuthContext,
action: string, action: string,
@@ -2343,51 +2467,51 @@ const writeAuditLog = async (
} }
}; };
const writeBirdTimelineEvent = async ({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
}: {
birdId: string;
eventType: BirdTimelineEventType;
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
}) => {
try {
await createBirdTimelineEvent({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
});
} catch (error) {
console.error('Unable to write bird timeline event', error);
}
};
const isBillingOnlyWorkspaceUpdate = ( const isBillingOnlyWorkspaceUpdate = (
workspace: WorkspaceRow, workspace: WorkspaceRow,
payload: z.infer<typeof workspaceSchema>, payload: z.infer<typeof workspaceSchema>,
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name; ) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
app.get('/api/health/live', (_req: Request, res: Response) => { app.get('/api/health', (_req: Request, res: Response) => {
res.json({ res.json({ ok: true });
ok: true,
service: 'flockpal-backend',
status: 'live',
uptimeSeconds: Math.round(process.uptime()),
checkedAt: new Date().toISOString(),
});
});
app.get('/api/health/ready', async (_req: Request, res: Response) => {
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
const ok = postgres.ok && redis.ok;
res.status(ok ? 200 : 503).json({
ok,
service: 'flockpal-backend',
status: ok ? 'ready' : 'degraded',
checkedAt: new Date().toISOString(),
dependencies: {
postgres,
redis,
},
});
});
app.get('/api/health', async (_req: Request, res: Response) => {
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
const ok = postgres.ok && redis.ok;
res.status(ok ? 200 : 503).json({
ok,
service: 'flockpal-backend',
status: ok ? 'ready' : 'degraded',
checkedAt: new Date().toISOString(),
dependencies: {
postgres,
redis,
},
});
}); });
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
@@ -2419,7 +2543,6 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
queues: { queues: {
birdMilestoneReminders: birdMilestoneReminderQueueCounts, birdMilestoneReminders: birdMilestoneReminderQueueCounts,
medicationReminders: medicationReminderQueueCounts, medicationReminders: medicationReminderQueueCounts,
adoptionReports: await getAdoptionReportQueueCounts(),
}, },
}); });
} catch (error) { } catch (error) {
@@ -2814,6 +2937,12 @@ app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, asy
rescueBirds: Number(summary?.rescue_birds ?? 0), rescueBirds: Number(summary?.rescue_birds ?? 0),
pendingRescues: Number(summary?.pending_rescues ?? 0), pendingRescues: Number(summary?.pending_rescues ?? 0),
dailyUsers: Number(summary?.daily_users ?? 0), dailyUsers: Number(summary?.daily_users ?? 0),
subscriptionsByPlan: {
household_basic: Number(summary?.household_basic_subscriptions ?? 0),
household_plus: Number(summary?.household_plus_subscriptions ?? 0),
household_macaw: Number(summary?.household_macaw_subscriptions ?? 0),
household_hyacinth_macaw: Number(summary?.household_hyacinth_macaw_subscriptions ?? 0),
},
}, },
}); });
} catch (error) { } catch (error) {
@@ -3433,6 +3562,61 @@ app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(
} }
}); });
app.get('/api/locations/search', requireAuth, locationSearchLimiter, async (req: Request, res: Response, next: NextFunction) => {
const parsed = locationSearchSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ error: 'Enter at least 3 characters to search for a location.' });
return;
}
if (!mapboxAccessToken) {
res.status(503).json({ error: 'Location search is not configured.' });
return;
}
const query = parsed.data.q.replace(/\s+/g, ' ').trim();
const cacheKey = query.toLocaleLowerCase();
const cached = mapboxLocationCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
res.json({ results: cached.results });
return;
}
try {
const searchUrl = new URL('https://api.mapbox.com/search/geocode/v6/forward');
searchUrl.searchParams.set('q', query);
searchUrl.searchParams.set('access_token', mapboxAccessToken);
searchUrl.searchParams.set('autocomplete', 'false');
searchUrl.searchParams.set('types', 'place,locality,region,country');
searchUrl.searchParams.set('limit', '3');
searchUrl.searchParams.set('permanent', 'true');
const mapboxResponse = await fetch(searchUrl);
const data = (await mapboxResponse.json().catch(() => null)) as MapboxGeocodeResponse | null;
if (!mapboxResponse.ok) {
res.status(502).json({ error: data?.message || 'Location search failed.' });
return;
}
const results = (data?.features ?? [])
.map(normalizeMapboxLocationFeature)
.filter((result): result is VerifiedLocationSearchResult => result !== null)
.filter((result, index, allResults) => allResults.findIndex((entry) => entry.providerPlaceId === result.providerPlaceId) === index);
mapboxLocationCache.set(cacheKey, {
expiresAt: Date.now() + mapboxLocationCacheTtlMs,
results,
});
res.json({ results });
} catch (error) {
next(error);
}
});
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => { app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const [birds, memorializedBirds] = await Promise.all([ const [birds, memorializedBirds] = await Promise.all([
@@ -3445,6 +3629,71 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex
} }
}); });
app.get('/api/birds/:birdId/timeline', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const events = await listBirdTimelineEvents(req.params.birdId, req.auth!.workspace.id);
res.json({ events: events.map(normalizeBirdTimelineEvent) });
} catch (error) {
next(error);
}
});
app.post(
'/api/birds/:birdId/timeline',
requireAuth,
requireWriteAccess,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant', 'caregiver']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdTimelineEventSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid timeline payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
if (!ensureBirdWritable(bird, res)) {
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: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
locationDetails,
note: emptyToNull(parsed.data.note),
eventDate: emptyToNull(parsed.data.eventDate),
createdByUserId: req.auth!.user.id,
});
await writeAuditLog(req.auth!, 'bird.timeline_event_created', 'bird', bird.id, bird.name, {
eventType: parsed.data.eventType,
});
res.status(201).json({ event: normalizeBirdTimelineEvent(event!) });
} catch (error) {
next(error);
}
},
);
app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => { app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
try { try {
const token = typeof req.query.token === 'string' ? req.query.token : ''; const token = typeof req.query.token === 'string' ? req.query.token : '';
@@ -3516,29 +3765,13 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
let uploadedObjectKeyToCleanup: string | null = null; let uploadedObjectKeyToCleanup: string | null = null;
try { try {
const birdLimit = getBillingPlanBirdLimit(req.auth!.workspace.billing_plan);
if (birdLimit !== null) {
const currentBirdCount = await getWorkspaceBirdCount(req.auth!.workspace.id);
if (currentBirdCount >= birdLimit) {
res.status(409).json({
error: 'This flock has reached the bird limit for the selected plan. Upgrade the flock subscription or memorialize a bird before adding another.',
code: 'billing_plan_bird_limit_reached',
birdLimit,
currentBirdCount,
billingPlan: req.auth!.workspace.billing_plan,
});
return;
}
}
const birdId = crypto.randomUUID(); const birdId = crypto.randomUUID();
const photoStorage = await resolveBirdPhotoStorage({ const photoStorage = await resolveBirdPhotoStorage({
birdId, birdId,
workspaceId: req.auth!.workspace.id, workspaceId: req.auth!.workspace.id,
photoDataUrl: emptyToNull(parsed.data.photoDataUrl), photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
}); });
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey; uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
const bird = await createBird({ const bird = await createBird({
birdId, birdId,
@@ -3549,12 +3782,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
motivators: emptyToNull(parsed.data.motivators), motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators), demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack), favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
locationDetails,
vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName), vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35', chartColor: parsed.data.chartColor ?? '#cb3a35',
photoDataUrl: photoStorage.photoDataUrl, photoDataUrl: photoStorage.photoDataUrl,
@@ -3572,6 +3807,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
species: bird!.species, species: bird!.species,
tagId: bird!.tag_id, tagId: bird!.tag_id,
}); });
await writeBirdTimelineEvent({
birdId: bird!.id,
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!) }); res.status(201).json({ bird: normalizeBird(bird!) });
} catch (error) { } catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
@@ -3656,6 +3899,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
destinationOwnerEmail, destinationOwnerEmail,
destinationWorkspaceId: targetWorkspace.id, destinationWorkspaceId: targetWorkspace.id,
}); });
await writeBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: req.auth!.workspace.id,
toWorkspaceId: targetWorkspace.id,
createdByUserId: req.auth!.user.id,
});
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
@@ -3686,7 +3936,25 @@ app.post(
return; return;
} }
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id); 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) { if (!transferCode) {
throw new Error('Unable to create bird transfer code.'); throw new Error('Unable to create bird transfer code.');
@@ -3708,30 +3976,6 @@ app.post(
}, },
); );
app.get('/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;
}
const transferCode = await getOpenBirdTransferCodeForBird(sourceBird.id, req.auth!.workspace.id);
res.json({
transferCode: transferCode
? {
code: transferCode.code,
bird: normalizeBird(sourceBird),
}
: null,
});
} catch (error) {
next(error);
}
});
app.post( app.post(
'/api/bird-transfer-codes/:code/accept', '/api/bird-transfer-codes/:code/accept',
requireAuth, requireAuth,
@@ -3772,6 +4016,13 @@ app.post(
sourceWorkspaceName: transferCode.workspace_name, sourceWorkspaceName: transferCode.workspace_name,
transferCodeId: transferCode.transfer_code_id, transferCodeId: transferCode.transfer_code_id,
}); });
await writeBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transferCode.source_workspace_id,
toWorkspaceId: req.auth!.workspace.id,
createdByUserId: req.auth!.user.id,
});
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) }); res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
} catch (error) { } catch (error) {
@@ -3804,7 +4055,25 @@ app.post(
return; return;
} }
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id); 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) { if (!transferCode) {
throw new Error('Unable to create bird transfer code.'); throw new Error('Unable to create bird transfer code.');
@@ -3872,6 +4141,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
}); });
uploadedObjectKeyToCleanup = uploadedObjectKeyToCleanup =
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null; photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
const bird = await updateBird({ const bird = await updateBird({
birdId: req.params.birdId, birdId: req.params.birdId,
workspaceId: req.auth!.workspace.id, workspaceId: req.auth!.workspace.id,
@@ -3881,12 +4151,14 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators), motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators), demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack), favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
locationDetails,
vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName), vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35', chartColor: parsed.data.chartColor ?? '#cb3a35',
photoDataUrl: photoStorage.photoDataUrl, photoDataUrl: photoStorage.photoDataUrl,
@@ -3910,6 +4182,19 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
previousName: existingBird.name, previousName: existingBird.name,
species: bird.species, species: bird.species,
}); });
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,
});
}
res.json({ bird: normalizeBird(bird) }); res.json({ bird: normalizeBird(bird) });
} catch (error) { } catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
+35 -5
View File
@@ -215,6 +215,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000), motivators VARCHAR(1000),
demotivators VARCHAR(1000), demotivators VARCHAR(1000),
favorite_snack VARCHAR(160), favorite_snack VARCHAR(160),
location_label VARCHAR(160),
location_details JSONB,
vet_clinic_name VARCHAR(160), vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500), vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120), vet_account_number VARCHAR(120),
@@ -243,6 +245,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), 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_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500), 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_account_number VARCHAR(120),
@@ -342,9 +346,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC) ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id) ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_transfer_codes ( CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -368,9 +372,35 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE completed_at IS NULL WHERE completed_at IS NULL
AND revoked_at IS NULL; AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes ( CREATE TABLE IF NOT EXISTS bird_timeline_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
event_type VARCHAR(40) NOT NULL,
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
from_workspace_name VARCHAR(160),
to_workspace_name VARCHAR(160),
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,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
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,
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL, bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
title VARCHAR(160) NOT NULL, title VARCHAR(160) NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
@@ -40,5 +40,3 @@ export const closeAdoptionReportQueue = async () => {
await adoptionReportQueue.close(); await adoptionReportQueue.close();
await adoptionReportQueueEvents.close(); await adoptionReportQueueEvents.close();
}; };
export const getAdoptionReportQueueCounts = () => adoptionReportQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
+14 -32
View File
@@ -103,26 +103,10 @@ const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, wi
return doc.y; return doc.y;
}; };
const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => { const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => {
doc.font('Helvetica-Bold').fontSize(10); doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border);
const textHeight = doc.heightOfString(value, {
width: width - 16,
lineGap: 1,
});
return Math.max(minHeight, 27 + Math.min(textHeight, 38));
};
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height?: number) => {
const cardHeight = height ?? measureFactHeight(doc, value, width);
doc.roundedRect(x, y, width, cardHeight, 6).fillAndStroke(colors.panel, colors.border);
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 }); doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true });
width: width - 16,
height: cardHeight - 27,
lineGap: 1,
ellipsis: true,
});
return cardHeight;
}; };
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => { const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
@@ -160,12 +144,8 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi
} }
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`); const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
const earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`);
const startDate = new Date(latestDate); const startDate = new Date(latestDate);
startDate.setUTCDate(startDate.getUTCDate() - 13); startDate.setUTCDate(startDate.getUTCDate() - 29);
if (earliestDate > startDate) {
startDate.setTime(earliestDate.getTime());
}
const visibleWeights = plottedWeights.filter((entry) => { const visibleWeights = plottedWeights.filter((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`); const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return recordedOn >= startDate && recordedOn <= latestDate; return recordedOn >= startDate && recordedOn <= latestDate;
@@ -377,14 +357,16 @@ export const renderAdoptionReportPdf = async ({
y = page.margin; y = page.margin;
} }
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y); y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth); const vetFacts = [
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth); ['Clinic name', bird.vet_clinic_name || 'Not recorded'],
y += 50; ['Clinic address', bird.vet_clinic_address || 'Not recorded'],
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58); ['Account #', bird.vet_account_number || 'Not recorded'],
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight); ['Dr. name', bird.vet_doctor_name || 'Not recorded'],
y += clinicAddressHeight + 7; ];
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth); vetFacts.forEach(([label, value], index) => {
y += 50; drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
});
y += Math.ceil(vetFacts.length / 2) * 50 + 8;
y = drawSectionTitle(doc, 'Vet Visit History', y); y = drawSectionTitle(doc, 'Vet Visit History', y);
y = drawTable( y = drawTable(
+1 -5
View File
@@ -1,12 +1,8 @@
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import {
getBirdById,
listVetVisitsForBird,
listWeightsForBird,
} from '../repositories/birdRepository.js';
import { listFlockNotes } from '../repositories/auditRepository.js'; import { listFlockNotes } from '../repositories/auditRepository.js';
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js'; import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js'; import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
import type { BirdRow } from '../types.js'; import type { BirdRow } from '../types.js';
+55 -12
View File
@@ -7,7 +7,6 @@ import {
createPendingBirdTransfer, createPendingBirdTransfer,
getBirdById, getBirdById,
getOpenBirdTransferCode, getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listWeightsForBird, listWeightsForBird,
markBirdTransferCodeCompleted, markBirdTransferCodeCompleted,
transferBirdToWorkspace, transferBirdToWorkspace,
@@ -191,6 +190,46 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
], ],
}, },
{ rowCount: 1, rows: [] }, { rowCount: 1, rows: [] },
{
rowCount: 1,
rows: [
{
workspace_id: 10,
workspace_name: 'Original Flock',
owner_email: 'sender@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
workspace_id: 22,
workspace_name: 'Receiving Flock',
owner_email: 'receiver@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
id: 'timeline-1',
bird_id: 'bird-1',
event_type: 'transferred',
from_workspace_id: 10,
to_workspace_id: 22,
from_workspace_name: 'Original Flock',
to_workspace_name: 'Receiving Flock',
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',
},
],
},
); );
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22); const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
@@ -200,6 +239,21 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]); assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/); assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
assert.deepEqual(calls[5].params, [
'bird-1',
'transferred',
10,
22,
'Original Flock',
'Receiving Flock',
'sender@example.com',
'receiver@example.com',
null,
null,
null,
'user-1',
null,
]);
}); });
test('getOpenBirdTransferCode only returns unconsumed codes', async () => { test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
@@ -214,17 +268,6 @@ test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/); assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
}); });
test('getOpenBirdTransferCodeForBird ignores consumed codes', async () => {
const { calls } = mockDb({ rowCount: 0, rows: [] });
const transferCode = await getOpenBirdTransferCodeForBird('bird-1', 10);
assert.equal(transferCode, null);
assert.deepEqual(calls[0].params, ['bird-1', 10]);
assert.match(calls[0].text, /completed_at IS NULL/);
assert.match(calls[0].text, /revoked_at IS NULL/);
});
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => { test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
const { calls } = mockDb({ rowCount: 1, rows: [] }); const { calls } = mockDb({ rowCount: 1, rows: [] });
+177 -40
View File
@@ -5,6 +5,8 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTimelineEventRow,
BirdTimelineEventType,
BirdTransferCodeRow, BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
@@ -26,6 +28,8 @@ const birdSelectFields = `
birds.motivators, birds.motivators,
birds.demotivators, birds.demotivators,
birds.favorite_snack, birds.favorite_snack,
birds.location_label,
birds.location_details,
birds.vet_clinic_name, birds.vet_clinic_name,
birds.vet_clinic_address, birds.vet_clinic_address,
birds.vet_account_number, birds.vet_account_number,
@@ -51,6 +55,34 @@ const birdSelectFields = `
latest.recorded_on::text AS latest_recorded_on latest.recorded_on::text AS latest_recorded_on
`; `;
type WorkspaceTimelineSnapshot = {
workspace_id: number;
workspace_name: string;
owner_email: string | null;
};
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
const result = await db.query<WorkspaceTimelineSnapshot>(
`SELECT
workspaces.id AS workspace_id,
workspaces.name AS workspace_name,
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
FROM workspaces
LEFT JOIN LATERAL (
SELECT invite_email, email
FROM workspace_members
WHERE workspace_members.workspace_id = workspaces.id
AND workspace_members.role = 'owner'
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
LIMIT 1
) owner_member ON TRUE
WHERE workspaces.id = $1`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const getBirdById = async (birdId: string, workspaceId: number) => { export const getBirdById = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`SELECT `SELECT
@@ -134,6 +166,102 @@ export const listMemorializedBirds = async (workspaceId: number) => {
return result.rows; return result.rows;
}; };
export const createBirdTimelineEvent = async ({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
}: {
birdId: string;
eventType: BirdTimelineEventType;
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
}) => {
const [fromWorkspace, toWorkspace] = await Promise.all([
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
]);
const result = await db.query<BirdTimelineEventRow>(
`INSERT INTO bird_timeline_events (
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,
created_by_user_id,
location_details
)
VALUES (
$1,
$2,
$3,
$4,
$5::varchar(160),
$6::varchar(160),
$7::varchar(320),
$8::varchar(320),
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
$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,
fromWorkspaceId ?? null,
toWorkspaceId ?? null,
fromWorkspace?.workspace_name ?? null,
toWorkspace?.workspace_name ?? null,
fromWorkspace?.owner_email ?? null,
toWorkspace?.owner_email ?? null,
locationLabel ?? null,
note ?? null,
eventDate ?? null,
createdByUserId ?? null,
locationDetails ?? null,
],
);
return result.rows[0] ?? null;
};
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, location_details, note, event_date::text, created_by_user_id, created_at
FROM bird_timeline_events
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = bird_timeline_events.bird_id
AND birds.workspace_id = $2
)
ORDER BY event_date DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const findBirdsByBandId = async (tagId: string) => { export const findBirdsByBandId = async (tagId: string) => {
const result = await db.query<LostBirdMatchRow>( const result = await db.query<LostBirdMatchRow>(
`SELECT `SELECT
@@ -367,6 +495,8 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel = null,
locationDetails = null,
vetClinicName = null, vetClinicName = null,
vetClinicAddress = null, vetClinicAddress = null,
vetAccountNumber = null, vetAccountNumber = null,
@@ -392,6 +522,8 @@ export const createBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName?: string | null; vetClinicName?: string | null;
vetClinicAddress?: string | null; vetClinicAddress?: string | null;
vetAccountNumber?: string | null; vetAccountNumber?: string | null;
@@ -410,9 +542,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean; publicProfileEnabled?: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) `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) 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, 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`, 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, birdId ?? null,
workspaceId, workspaceId,
@@ -422,6 +554,8 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
locationDetails,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -453,6 +587,8 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
locationDetails,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -478,6 +614,8 @@ export const updateBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
locationLabel: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName: string | null; vetClinicName: string | null;
vetClinicAddress: string | null; vetClinicAddress: string | null;
vetAccountNumber: string | null; vetAccountNumber: string | null;
@@ -503,26 +641,28 @@ export const updateBird = async ({
motivators = $5, motivators = $5,
demotivators = $6, demotivators = $6,
favorite_snack = $7, favorite_snack = $7,
vet_clinic_name = $8, location_label = $8,
vet_clinic_address = $9, vet_clinic_name = $9,
vet_account_number = $10, vet_clinic_address = $10,
vet_doctor_name = $11, vet_account_number = $11,
gender = $12, vet_doctor_name = $12,
date_of_birth = $13, gender = $13,
gotcha_day = $14, date_of_birth = $14,
chart_color = $15, gotcha_day = $15,
photo_data_url = $16, chart_color = $16,
photo_object_key = $17, photo_data_url = $17,
photo_content_type = $18, photo_object_key = $18,
photo_updated_at = $19, photo_content_type = $19,
notify_on_dob = $20, photo_updated_at = $20,
notify_on_gotcha_day = $21, notify_on_dob = $21,
public_profile_code = $22, notify_on_gotcha_day = $22,
public_profile_enabled = $23 public_profile_code = $23,
public_profile_enabled = $24,
location_details = $25
WHERE id = $1 WHERE id = $1
AND workspace_id = $24 AND workspace_id = $26
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, 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 SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -545,6 +685,7 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -561,6 +702,7 @@ export const updateBird = async ({
notifyOnGotchaDay, notifyOnGotchaDay,
publicProfileCode, publicProfileCode,
publicProfileEnabled, publicProfileEnabled,
locationDetails ?? null,
workspaceId, workspaceId,
], ],
); );
@@ -590,7 +732,7 @@ export const memorializeBird = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, 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 SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -626,7 +768,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NOT NULL AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, 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 SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -666,7 +808,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, 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 SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -762,6 +904,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
} }
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId); await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
try {
await createBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transfer.source_workspace_id,
toWorkspaceId: targetWorkspaceId,
createdByUserId: transfer.requested_by_user_id,
});
} catch (timelineError) {
console.error('Unable to write bird timeline event', timelineError);
}
completed += 1; completed += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
@@ -809,22 +962,6 @@ export const createBirdTransferCode = async ({
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => {
const result = await db.query<BirdTransferCodeRow>(
`SELECT id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at
FROM bird_transfer_codes
WHERE bird_id = $1
AND source_workspace_id = $2
AND completed_at IS NULL
AND revoked_at IS NULL
ORDER BY created_at DESC
LIMIT 1`,
[birdId, sourceWorkspaceId],
);
return result.rows[0] ?? null;
};
export const getOpenBirdTransferCode = async (code: string) => { export const getOpenBirdTransferCode = async (code: string) => {
const result = await db.query< const result = await db.query<
BirdRow & { BirdRow & {
@@ -531,6 +531,10 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
rescue_birds: 5, rescue_birds: 5,
pending_rescues: 1, pending_rescues: 1,
daily_users: 2, daily_users: 2,
household_basic_subscriptions: 2,
household_plus_subscriptions: 1,
household_macaw_subscriptions: 0,
household_hyacinth_macaw_subscriptions: 1,
}, },
], ],
}); });
@@ -543,4 +547,7 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
assert.match(calls[0].text, /memorialized_at IS NOT NULL/); assert.match(calls[0].text, /memorialized_at IS NOT NULL/);
assert.match(calls[0].text, /rescue_birds/); assert.match(calls[0].text, /rescue_birds/);
assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/); assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/);
assert.equal(summary?.household_basic_subscriptions, 2);
assert.match(calls[0].text, /billing_plan = 'household_basic'/);
assert.match(calls[0].text, /subscription_status IN \('active', 'trialing'\)/);
}); });
@@ -605,6 +605,10 @@ export const getPlatformAdminSummary = async () => {
rescue_birds: number; rescue_birds: number;
pending_rescues: number; pending_rescues: number;
daily_users: number; daily_users: number;
household_basic_subscriptions: number;
household_plus_subscriptions: number;
household_macaw_subscriptions: number;
household_hyacinth_macaw_subscriptions: number;
}>( }>(
`SELECT `SELECT
(SELECT COUNT(*)::int FROM birds) AS total_birds, (SELECT COUNT(*)::int FROM birds) AS total_birds,
@@ -614,7 +618,11 @@ export const getPlatformAdminSummary = async () => {
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces, (SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces,
(SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds, (SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds,
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues, (SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues,
(SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users`, (SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users,
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_basic' AND subscription_status IN ('active', 'trialing')) AS household_basic_subscriptions,
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_plus' AND subscription_status IN ('active', 'trialing')) AS household_plus_subscriptions,
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_macaw' AND subscription_status IN ('active', 'trialing')) AS household_macaw_subscriptions,
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_hyacinth_macaw' AND subscription_status IN ('active', 'trialing')) AS household_hyacinth_macaw_subscriptions`,
); );
return result.rows[0]; return result.rows[0];
+22
View File
@@ -101,6 +101,8 @@ export type BirdRow = {
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favorite_snack: string | null; favorite_snack: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
vet_clinic_name: string | null; vet_clinic_name: string | null;
vet_clinic_address: string | null; vet_clinic_address: string | null;
vet_account_number: string | null; vet_account_number: string | null;
@@ -174,6 +176,26 @@ export type BirdTransferCodeRow = {
created_at: string; created_at: string;
}; };
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
export type BirdTimelineEventRow = {
id: string;
bird_id: string;
event_type: BirdTimelineEventType;
from_workspace_id: number | null;
to_workspace_id: number | null;
from_workspace_name: string | null;
to_workspace_name: string | null;
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;
created_at: string;
};
export type WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
+2
View File
@@ -55,6 +55,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -142,6 +143,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
+3 -1
View File
@@ -53,6 +53,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -129,6 +130,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -160,7 +162,7 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: flockpal-frontend container_name: flockpal-frontend
environment: environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
depends_on: depends_on:
- backend - backend
ports: ports:
+1
View File
@@ -7,6 +7,7 @@ RUN npm ci
COPY tsconfig*.json ./ COPY tsconfig*.json ./
COPY vite.config.ts ./ COPY vite.config.ts ./
COPY index.html ./ COPY index.html ./
COPY public ./public
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
+1
View File
@@ -5,6 +5,7 @@ RUN npm install
COPY tsconfig*.json ./ COPY tsconfig*.json ./
COPY vite.config.ts ./ COPY vite.config.ts ./
COPY index.html ./ COPY index.html ./
COPY public ./public
COPY src ./src COPY src ./src
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"] CMD ["npm", "run", "dev", "--", "--host"]
+1003 -686
View File
File diff suppressed because it is too large Load Diff
+330 -11
View File
@@ -1,3 +1,4 @@
:root { :root {
--ink: #1f2a2a; --ink: #1f2a2a;
--muted: #5d5f59; --muted: #5d5f59;
@@ -616,14 +617,6 @@ textarea {
break-inside: avoid; break-inside: avoid;
} }
.settings-card-bird-profiles {
order: 1;
}
.settings-card-bird-profiles[hidden] {
display: none;
}
.settings-card-collaborators { .settings-card-collaborators {
order: 2; order: 2;
} }
@@ -1215,6 +1208,264 @@ textarea {
align-items: center; align-items: center;
} }
.bird-timeline-card {
grid-template-columns: 18px minmax(0, 1fr);
gap: 0.75rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
}
.bird-timeline-graph-card {
padding: 0.85rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.bird-timeline-graph {
position: relative;
min-height: 340px;
isolation: isolate;
}
.bird-timeline-graph-line {
position: absolute;
left: calc(8.125% + 18px);
right: 8.125%;
top: 50%;
height: 4px;
border-radius: 999px;
background: var(--timeline-color, var(--accent-green));
transform: translateY(-50%);
}
.bird-timeline-graph-scale {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.bird-timeline-graph-tick {
position: absolute;
top: calc(50% + 78px);
transform: translateX(-50%);
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.bird-timeline-graph-tick::before {
content: "";
position: absolute;
left: 50%;
bottom: calc(100% + 0.35rem);
width: 1px;
height: 68px;
background: linear-gradient(to bottom, rgba(39, 105, 179, 0.22), rgba(39, 105, 179, 0));
transform: translateX(-50%);
}
.bird-timeline-graph-tick.today {
color: var(--accent-green);
font-weight: 800;
}
.bird-timeline-graph-tick.today::before {
background: linear-gradient(to bottom, rgba(35, 138, 90, 0.42), rgba(35, 138, 90, 0));
}
.bird-timeline-graph-point {
position: absolute;
top: 50%;
width: 136px;
height: 0;
transform: translateX(-50%);
z-index: 1;
}
.bird-timeline-graph-dot {
position: absolute;
left: 50%;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 0;
border-radius: 0;
background: transparent;
}
.bird-timeline-graph-point.above .bird-timeline-graph-dot {
left: calc(50% + var(--branch-offset, 0px));
bottom: var(--branch-distance, 34px);
transform: translate(-50%, 50%);
}
.bird-timeline-graph-point.below .bird-timeline-graph-dot {
left: calc(50% + var(--branch-offset, 0px));
top: var(--branch-distance, 34px);
transform: translate(-50%, -50%);
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-dot {
top: 0;
transform: translate(-50%, -50%);
}
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-dot {
width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
background: #fffdf9;
}
.bird-timeline-graph-connector {
position: absolute;
left: 50%;
width: 2px;
height: var(--branch-connector-length, var(--branch-distance, 34px));
background: repeating-linear-gradient(
to bottom,
rgba(39, 105, 179, 0.18) 0 4px,
transparent 4px 9px
);
transform: translateX(-50%) rotate(var(--branch-angle, 0deg));
}
.bird-timeline-graph-point.above .bird-timeline-graph-connector {
bottom: 0;
transform-origin: bottom center;
}
.bird-timeline-graph-point.below .bird-timeline-graph-connector {
top: 0;
transform-origin: top center;
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-connector {
display: none;
}
.bird-timeline-graph-icon {
width: 24px;
height: 24px;
color: var(--accent-green);
fill: currentColor;
}
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-icon {
width: 34px;
height: 34px;
color: var(--accent-gold);
font-size: 34px;
}
.bird-timeline-graph-point.owner_changed .bird-timeline-graph-icon {
color: var(--accent-blue);
}
.bird-timeline-graph-point.transferred .bird-timeline-graph-icon {
color: var(--accent-red);
}
.bird-timeline-graph-copy {
position: absolute;
left: 50%;
width: 124px;
transform: translateX(-50%);
display: grid;
gap: 0.08rem;
justify-items: center;
text-align: center;
color: var(--ink);
font-size: 0.68rem;
}
.bird-timeline-graph-copy strong {
font-weight: 700;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bird-timeline-graph-copy span {
color: var(--muted);
line-height: 1.1;
}
.bird-timeline-graph-point.above .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
bottom: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.below .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
top: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-copy {
bottom: 28px;
}
.bird-timeline-form {
padding: 0.85rem;
border: 1px solid rgba(35, 138, 90, 0.14);
border-radius: 8px;
background: rgba(240, 248, 244, 0.54);
}
.bird-timeline-marker {
width: 12px;
height: 12px;
margin-top: 0.25rem;
border-radius: 999px;
background: var(--accent-green);
box-shadow: 0 0 0 4px rgba(35, 138, 90, 0.12);
}
.bird-timeline-content {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.bird-timeline-content > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.bird-timeline-content strong,
.bird-timeline-content span,
.bird-timeline-content small,
.bird-timeline-content p {
overflow-wrap: anywhere;
}
.bird-timeline-content span,
.bird-timeline-content small {
color: var(--muted);
}
.bird-timeline-content p {
margin: 0;
color: var(--ink);
}
.legend-grid, .legend-grid,
.detail-grid, .detail-grid,
.summary-grid { .summary-grid {
@@ -1314,6 +1565,7 @@ textarea {
.bird-detail-tab .info-tab-icon, .bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon, .bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon, .bird-detail-tab .report-tab-icon,
.bird-detail-tab .timeline-tab-icon,
.bird-detail-tab .audit-tab-icon, .bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon { .bird-detail-tab .vet-tab-icon {
width: 24px; width: 24px;
@@ -1342,7 +1594,7 @@ textarea {
.profile-copy { .profile-copy {
display: grid; display: grid;
gap: 0.3rem; gap: 0.18rem;
} }
.profile-copy h3 { .profile-copy h3 {
@@ -1350,6 +1602,10 @@ textarea {
font-size: 1.6rem; font-size: 1.6rem;
} }
.profile-copy p {
margin: 0;
}
.profile-title { .profile-title {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1798,6 +2054,60 @@ label {
font-size: 0.95rem; font-size: 0.95rem;
} }
.verified-location-field {
display: grid;
gap: 0.75rem;
}
.verified-location-label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
.verified-location-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: end;
}
.verified-location-search-row.has-selected-location {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.verified-location-search-row.has-selected-location input {
border-color: rgba(35, 138, 90, 0.45);
background: rgba(35, 138, 90, 0.08);
box-shadow: 0 0 0 3px rgba(35, 138, 90, 0.08);
}
.verified-location-result small {
color: var(--muted);
}
.verified-location-results {
display: grid;
gap: 0.45rem;
}
.verified-location-result {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.8rem 0.9rem;
text-align: left;
color: var(--ink);
border: 1px solid rgba(53, 129, 98, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
}
.verified-location-result:hover {
border-color: rgba(39, 105, 179, 0.28);
background: rgba(255, 255, 255, 0.78);
}
.toggle-card input[type="checkbox"] { .toggle-card input[type="checkbox"] {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -2127,7 +2437,8 @@ label {
.inline-form, .inline-form,
.profile-hero, .profile-hero,
.photo-editor, .photo-editor,
.settings-nested-grid { .settings-nested-grid,
.verified-location-search-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -2204,17 +2515,25 @@ label {
} }
.page-tabs { .page-tabs {
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); grid-auto-flow: column;
grid-auto-columns: minmax(5.5rem, max-content);
grid-template-columns: none;
gap: 0.4rem; gap: 0.4rem;
min-width: 0;
overflow-x: auto;
padding-bottom: 0.1rem;
scrollbar-width: thin;
} }
.page-tab { .page-tab {
min-height: 42px; min-height: 42px;
min-width: 5.5rem;
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 14px; border-radius: 14px;
text-align: center; text-align: center;
font-size: 0.92rem; font-size: 0.92rem;
font-weight: 700; font-weight: 700;
white-space: nowrap;
} }
.side-nav .secondary-button { .side-nav .secondary-button {
+6
View File
@@ -5,5 +5,11 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 3000,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
}, },
}); });