diff --git a/.env.example b/.env.example index 1ed0bfe..d97f1a7 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:5000 VITE_API_BASE_URL=http://localhost:5000/api +MAPBOX_ACCESS_TOKEN= NODE_ENV=development TRUST_PROXY= ADMIN_EMAILS=corey@blaishome.online diff --git a/README.md b/README.md index d91cdf3..6a97fef 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ curl -H "Authorization: Bearer " https://your-host/api/metrics - `FRONTEND_URL` - `BACKEND_URL` - `VITE_API_BASE_URL` + - `MAPBOX_ACCESS_TOKEN` - `REDIS_URL` - `IMAGE_STORAGE_PROVIDER` - `S3_ENDPOINT` diff --git a/backend/src/app.ts b/backend/src/app.ts index 079f7e6..cffef15 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,9 +12,8 @@ import nodemailer, { type SendMailOptions } from 'nodemailer'; import Stripe from 'stripe'; import { z } from 'zod'; -import { db } from './db/client.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 { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js'; import { @@ -38,6 +37,7 @@ import { completePendingBirdTransfersForOwner, createBird, createBirdMilestoneReminderDelivery, + createBirdTimelineEvent, createMedicationReminderDelivery, createBirdTransferCode, createMedicationForBird, @@ -51,8 +51,8 @@ import { getBirdById, getBirdByPublicProfileCode, getOpenBirdTransferCode, - getOpenBirdTransferCodeForBird, listBirds, + listBirdTimelineEvents, listDueBirdMilestoneReminders, listDueMedicationReminders, listMemorializedBirds, @@ -98,7 +98,6 @@ import { getMembershipForUser, getNextWorkspaceId, getWorkspaceById, - getWorkspaceBirdCount, getWorkspaceTotalBirdCount, listOwnedWorkspacesByOwnerEmail, listRescueWorkspacesForAdmin, @@ -114,14 +113,16 @@ import { upsertWorkspaceMember, } from './repositories/workspaceRepository.js'; import type { - AuthContext, - AuditLogEntryRow, - BillingInterval, - BillingPlan, - BirdGender, + AuthContext, + AuditLogEntryRow, + BillingInterval, + BillingPlan, + BirdGender, BirdMilestoneReminderCandidateRow, - BirdRow, - FlockNoteRow, + BirdRow, + BirdTimelineEventType, + BirdTimelineEventRow, + FlockNoteRow, IntegrationTokenRow, LostBirdMatchRow, MedicationAdministrationRow, @@ -270,6 +271,26 @@ const birdProfileListSchema = z .optional() .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({ name: z.string().trim().min(1).max(120), tagId: z.string().trim().max(80).optional().or(z.literal('')), @@ -277,11 +298,14 @@ const birdSchema = z.object({ motivators: birdProfileListSchema, demotivators: birdProfileListSchema, favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), + locationLabel: z.string().trim().max(160).optional().or(z.literal('')), + locationDetails: verifiedLocationDetailsSchema, vetClinicName: z.string().trim().max(160).optional().or(z.literal('')), vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')), vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')), vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')), gender: birdGenderSchema.optional(), + hatchDay: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')), chartColor: chartColorSchema.optional(), @@ -301,6 +325,146 @@ const memorialReminderPreferenceSchema = z.object({ 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; + +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) => + details ? details.label || [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null; + +type VerifiedLocationSearchResult = NonNullable>; + +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(); +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({ weightGrams: z.coerce.number().positive().max(10000), recordedOn: dateStringSchema, @@ -637,11 +801,14 @@ const normalizeBird = (row: BirdRow) => ({ motivators: row.motivators, demotivators: row.demotivators, favoriteSnack: row.favorite_snack, + locationLabel: row.location_label, + locationDetails: row.location_details, vetClinicName: row.vet_clinic_name, vetClinicAddress: row.vet_clinic_address, vetAccountNumber: row.vet_account_number, vetDoctorName: row.vet_doctor_name, gender: row.gender, + hatchDay: row.date_of_birth, dateOfBirth: row.date_of_birth, gotchaDay: row.gotcha_day, chartColor: row.chart_color, @@ -664,39 +831,13 @@ const normalizeBird = (row: BirdRow) => ({ 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) => ({ id: row.id, workspaceId: row.workspace_id, name: row.name, favoriteSnack: row.favorite_snack, gender: row.gender, + hatchDay: row.date_of_birth, dateOfBirth: row.date_of_birth, photoDataUrl: getBirdPhotoUrl(row), }); @@ -771,6 +912,24 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({ 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) => ({ id: row.id, userId: row.user_id, @@ -847,6 +1006,13 @@ const lostBirdReportLimiter = rateLimit({ legacyHeaders: false, 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) => { if (!stripeWebhookSecret) { 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'; }; -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 => { if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') { 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 defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png'); @@ -2267,59 +2444,6 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => { return false; }; -type HealthCheckResult = { - ok: boolean; - latencyMs?: number; - error?: string; -}; - -const withHealthTimeout = async (operation: Promise, timeoutMs = 2_000): Promise => { - let timeout: NodeJS.Timeout | undefined; - - try { - return await Promise.race([ - operation, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error('Health check timed out')), timeoutMs); - }), - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -}; - -const checkPostgresHealth = async (): Promise => { - 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 => { - 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 ( auth: AuthContext, 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 | 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 = ( workspace: WorkspaceRow, payload: z.infer, ) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name; -app.get('/api/health/live', (_req: Request, res: Response) => { - res.json({ - 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/health', (_req: Request, res: Response) => { + res.json({ ok: true }); }); 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: { birdMilestoneReminders: birdMilestoneReminderQueueCounts, medicationReminders: medicationReminderQueueCounts, - adoptionReports: await getAdoptionReportQueueCounts(), }, }); } catch (error) { @@ -2814,6 +2937,12 @@ app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, asy rescueBirds: Number(summary?.rescue_birds ?? 0), pendingRescues: Number(summary?.pending_rescues ?? 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) { @@ -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) => { try { 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) => { try { 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; 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 photoStorage = await resolveBirdPhotoStorage({ birdId, workspaceId: req.auth!.workspace.id, photoDataUrl: emptyToNull(parsed.data.photoDataUrl), }); + const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails); uploadedObjectKeyToCleanup = photoStorage.photoObjectKey; const bird = await createBird({ birdId, @@ -3549,12 +3782,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o motivators: emptyToNull(parsed.data.motivators), demotivators: emptyToNull(parsed.data.demotivators), favoriteSnack: emptyToNull(parsed.data.favoriteSnack), + locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel), + locationDetails, vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetDoctorName: emptyToNull(parsed.data.vetDoctorName), gender: (parsed.data.gender ?? 'unknown') as BirdGender, - dateOfBirth: emptyToNull(parsed.data.dateOfBirth), + dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: photoStorage.photoDataUrl, @@ -3572,6 +3807,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o species: bird!.species, 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!) }); } catch (error) { await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); @@ -3656,6 +3899,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require destinationOwnerEmail, 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) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { @@ -3686,7 +3936,25 @@ app.post( 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) { 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( '/api/bird-transfer-codes/:code/accept', requireAuth, @@ -3772,6 +4016,13 @@ app.post( sourceWorkspaceName: transferCode.workspace_name, 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) }); } catch (error) { @@ -3804,7 +4055,25 @@ app.post( 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) { throw new Error('Unable to create bird transfer code.'); @@ -3872,6 +4141,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR }); uploadedObjectKeyToCleanup = photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null; + const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails); const bird = await updateBird({ birdId: req.params.birdId, workspaceId: req.auth!.workspace.id, @@ -3881,12 +4151,14 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR motivators: emptyToNull(parsed.data.motivators), demotivators: emptyToNull(parsed.data.demotivators), favoriteSnack: emptyToNull(parsed.data.favoriteSnack), + locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel), + locationDetails, vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetDoctorName: emptyToNull(parsed.data.vetDoctorName), gender: (parsed.data.gender ?? 'unknown') as BirdGender, - dateOfBirth: emptyToNull(parsed.data.dateOfBirth), + dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: photoStorage.photoDataUrl, @@ -3910,6 +4182,19 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR previousName: existingBird.name, 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) }); } catch (error) { await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index f4ad8e1..59aed34 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -215,6 +215,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { motivators VARCHAR(1000), demotivators VARCHAR(1000), favorite_snack VARCHAR(160), + location_label VARCHAR(160), + location_details JSONB, vet_clinic_name VARCHAR(160), vet_clinic_address VARCHAR(500), vet_account_number VARCHAR(120), @@ -243,6 +245,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), + ADD COLUMN IF NOT EXISTS location_label VARCHAR(160), + ADD COLUMN IF NOT EXISTS location_details JSONB, ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160), ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500), ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120), @@ -342,9 +346,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC) WHERE completed_at IS NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird - ON pending_bird_transfers (bird_id) - WHERE completed_at IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird + ON pending_bird_transfers (bird_id) + WHERE completed_at IS NULL; CREATE TABLE IF NOT EXISTS bird_transfer_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -368,9 +372,35 @@ export const ensureSchema = async (database: DatabaseClient = db) => { WHERE completed_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(), - 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, title VARCHAR(160) NOT NULL, body TEXT NOT NULL, diff --git a/backend/src/queues/adoptionReportQueue.ts b/backend/src/queues/adoptionReportQueue.ts index be2897f..a1c60d6 100644 --- a/backend/src/queues/adoptionReportQueue.ts +++ b/backend/src/queues/adoptionReportQueue.ts @@ -40,5 +40,3 @@ export const closeAdoptionReportQueue = async () => { await adoptionReportQueue.close(); await adoptionReportQueueEvents.close(); }; - -export const getAdoptionReportQueueCounts = () => adoptionReportQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed'); diff --git a/backend/src/reports/adoptionReport.ts b/backend/src/reports/adoptionReport.ts index 49a321e..a490aac 100644 --- a/backend/src/reports/adoptionReport.ts +++ b/backend/src/reports/adoptionReport.ts @@ -103,26 +103,10 @@ const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, wi return doc.y; }; -const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => { - doc.font('Helvetica-Bold').fontSize(10); - 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); +const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => { + doc.roundedRect(x, y, width, 43, 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.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { - width: width - 16, - height: cardHeight - 27, - lineGap: 1, - ellipsis: true, - }); - return cardHeight; + doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true }); }; 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 earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`); const startDate = new Date(latestDate); - startDate.setUTCDate(startDate.getUTCDate() - 13); - if (earliestDate > startDate) { - startDate.setTime(earliestDate.getTime()); - } + startDate.setUTCDate(startDate.getUTCDate() - 29); const visibleWeights = plottedWeights.filter((entry) => { const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`); return recordedOn >= startDate && recordedOn <= latestDate; @@ -377,14 +357,16 @@ export const renderAdoptionReportPdf = async ({ y = page.margin; } y = drawSectionTitle(doc, 'Veterinary Clinic Info', y); - drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth); - drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth); - y += 50; - const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58); - drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight); - y += clinicAddressHeight + 7; - drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth); - y += 50; + const vetFacts = [ + ['Clinic name', bird.vet_clinic_name || 'Not recorded'], + ['Clinic address', bird.vet_clinic_address || 'Not recorded'], + ['Account #', bird.vet_account_number || 'Not recorded'], + ['Dr. name', bird.vet_doctor_name || 'Not recorded'], + ]; + vetFacts.forEach(([label, value], index) => { + 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 = drawTable( diff --git a/backend/src/reports/adoptionReportJob.ts b/backend/src/reports/adoptionReportJob.ts index 49fb0ad..f854e86 100644 --- a/backend/src/reports/adoptionReportJob.ts +++ b/backend/src/reports/adoptionReportJob.ts @@ -1,12 +1,8 @@ import path from 'path'; import sharp from 'sharp'; -import { - getBirdById, - listVetVisitsForBird, - listWeightsForBird, -} from '../repositories/birdRepository.js'; import { listFlockNotes } from '../repositories/auditRepository.js'; +import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js'; import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js'; import { getSignedS3ObjectUrl } from '../storage/s3Client.js'; import type { BirdRow } from '../types.js'; diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts index 3d2fb69..f5d15a3 100644 --- a/backend/src/repositories/birdRepository.test.ts +++ b/backend/src/repositories/birdRepository.test.ts @@ -7,7 +7,6 @@ import { createPendingBirdTransfer, getBirdById, getOpenBirdTransferCode, - getOpenBirdTransferCodeForBird, listWeightsForBird, markBirdTransferCodeCompleted, transferBirdToWorkspace, @@ -191,6 +190,46 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet ], }, { 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); @@ -200,6 +239,21 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet assert.deepEqual(calls[1].params, ['bird-1', 10, 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]); 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 () => { @@ -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/); }); -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 () => { const { calls } = mockDb({ rowCount: 1, rows: [] }); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index a25b500..dec5aad 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -5,6 +5,8 @@ import type { BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderType, BirdRow, + BirdTimelineEventRow, + BirdTimelineEventType, BirdTransferCodeRow, LostBirdMatchRow, MedicationAdministrationRow, @@ -26,6 +28,8 @@ const birdSelectFields = ` birds.motivators, birds.demotivators, birds.favorite_snack, + birds.location_label, + birds.location_details, birds.vet_clinic_name, birds.vet_clinic_address, birds.vet_account_number, @@ -51,6 +55,34 @@ const birdSelectFields = ` 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( + `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) => { const result = await db.query( `SELECT @@ -134,6 +166,102 @@ export const listMemorializedBirds = async (workspaceId: number) => { 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 | 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( + `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( + `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) => { const result = await db.query( `SELECT @@ -367,6 +495,8 @@ export const createBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel = null, + locationDetails = null, vetClinicName = null, vetClinicAddress = null, vetAccountNumber = null, @@ -392,6 +522,8 @@ export const createBird = async ({ motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + locationLabel?: string | null; + locationDetails?: Record | null; vetClinicName?: string | null; vetClinicAddress?: string | null; vetAccountNumber?: string | null; @@ -410,9 +542,9 @@ export const createBird = async ({ publicProfileEnabled?: boolean; }) => { const result = await db.query( - `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) - VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) + VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26) + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, [ birdId ?? null, workspaceId, @@ -422,6 +554,8 @@ export const createBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel, + locationDetails, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -453,6 +587,8 @@ export const updateBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel, + locationDetails, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -478,6 +614,8 @@ export const updateBird = async ({ motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + locationLabel: string | null; + locationDetails?: Record | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; @@ -503,26 +641,28 @@ export const updateBird = async ({ motivators = $5, demotivators = $6, favorite_snack = $7, - vet_clinic_name = $8, - vet_clinic_address = $9, - vet_account_number = $10, - vet_doctor_name = $11, - gender = $12, - date_of_birth = $13, - gotcha_day = $14, - chart_color = $15, - photo_data_url = $16, - photo_object_key = $17, - photo_content_type = $18, - photo_updated_at = $19, - notify_on_dob = $20, - notify_on_gotcha_day = $21, - public_profile_code = $22, - public_profile_enabled = $23 + location_label = $8, + vet_clinic_name = $9, + vet_clinic_address = $10, + vet_account_number = $11, + vet_doctor_name = $12, + gender = $13, + date_of_birth = $14, + gotcha_day = $15, + chart_color = $16, + photo_data_url = $17, + photo_object_key = $18, + photo_content_type = $19, + photo_updated_at = $20, + notify_on_dob = $21, + notify_on_gotcha_day = $22, + public_profile_code = $23, + public_profile_enabled = $24, + location_details = $25 WHERE id = $1 - AND workspace_id = $24 + AND workspace_id = $26 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 FROM weight_records @@ -545,6 +685,7 @@ export const updateBird = async ({ motivators, demotivators, favoriteSnack, + locationLabel, vetClinicName, vetClinicAddress, vetAccountNumber, @@ -561,6 +702,7 @@ export const updateBird = async ({ notifyOnGotchaDay, publicProfileCode, publicProfileEnabled, + locationDetails ?? null, workspaceId, ], ); @@ -590,7 +732,7 @@ export const memorializeBird = async ({ WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -626,7 +768,7 @@ export const updateMemorialReminderPreference = async ({ WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NOT NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -666,7 +808,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -762,6 +904,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t } 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; } catch (error) { failed += 1; @@ -809,22 +962,6 @@ export const createBirdTransferCode = async ({ return result.rows[0] ?? null; }; -export const getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => { - const result = await db.query( - `SELECT id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at - FROM bird_transfer_codes - WHERE bird_id = $1 - AND source_workspace_id = $2 - AND completed_at IS NULL - AND revoked_at IS NULL - ORDER BY created_at DESC - LIMIT 1`, - [birdId, sourceWorkspaceId], - ); - - return result.rows[0] ?? null; -}; - export const getOpenBirdTransferCode = async (code: string) => { const result = await db.query< BirdRow & { diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts index 645c0be..020bb48 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -531,6 +531,10 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () => rescue_birds: 5, pending_rescues: 1, 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, /rescue_birds/); 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'\)/); }); diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index e817262..89415b8 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -605,6 +605,10 @@ export const getPlatformAdminSummary = async () => { rescue_birds: number; pending_rescues: number; daily_users: number; + household_basic_subscriptions: number; + household_plus_subscriptions: number; + household_macaw_subscriptions: number; + household_hyacinth_macaw_subscriptions: number; }>( `SELECT (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 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(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]; diff --git a/backend/src/types.ts b/backend/src/types.ts index f8fd8a1..832702b 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -101,6 +101,8 @@ export type BirdRow = { motivators: string | null; demotivators: string | null; favorite_snack: string | null; + location_label: string | null; + location_details: Record | null; vet_clinic_name: string | null; vet_clinic_address: string | null; vet_account_number: string | null; @@ -174,6 +176,26 @@ export type BirdTransferCodeRow = { 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 | null; + note: string | null; + event_date: string; + created_by_user_id: string | null; + created_at: string; +}; + export type WeightRow = { id: string; bird_id: string; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5032ebb..12cbff6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -55,6 +55,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} 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} @@ -142,6 +143,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} 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} diff --git a/docker-compose.yml b/docker-compose.yml index 15fe01c..b7f4446 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} 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} @@ -129,6 +130,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} 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} @@ -160,7 +162,7 @@ services: dockerfile: Dockerfile.dev container_name: flockpal-frontend environment: - VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api} + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} depends_on: - backend ports: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 86caab1..a8648f7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,6 +7,7 @@ RUN npm ci COPY tsconfig*.json ./ COPY vite.config.ts ./ COPY index.html ./ +COPY public ./public COPY src ./src RUN npm run build diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index 1e1a14a..5845ca9 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -5,6 +5,7 @@ RUN npm install COPY tsconfig*.json ./ COPY vite.config.ts ./ COPY index.html ./ +COPY public ./public COPY src ./src EXPOSE 3000 CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4889542..346f875 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type CSSProperties, type Dispatch, type SetStateAction } from 'react'; import birdSilhouette from './assets/bird-silhouette.jpg'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; import flockPalTextArt from './assets/flockpal-text.png'; @@ -16,6 +16,27 @@ type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejec type IntegrationTokenScope = 'read_only' | 'read_write'; type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna'; +type VerifiedLocationDetails = { + label?: string | null; + city: string; + region: string; + country: string; + countryCode: string; + latitude: number | null; + longitude: number | null; + precision: 'city' | 'region' | 'country'; + provider?: 'mapbox' | null; + providerPlaceId?: string | null; + verifiedAt?: string; +}; + +type LocationSearchState = { + query: string; + results: VerifiedLocationDetails[]; + searching: boolean; + error: string; +}; + type Bird = { id: string; workspaceId?: number; @@ -25,11 +46,14 @@ type Bird = { motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + locationLabel: string | null; + locationDetails: VerifiedLocationDetails | null; vetClinicName: string | null; vetClinicAddress: string | null; vetAccountNumber: string | null; vetDoctorName: string | null; gender: BirdGender; + hatchDay: string | null; dateOfBirth: string | null; gotchaDay: string | null; chartColor: string; @@ -160,6 +184,7 @@ type AdminSummary = { rescueBirds: number; pendingRescues: number; dailyUsers: number; + subscriptionsByPlan: Record; }; type AdminRescueWorkspace = { @@ -207,6 +232,33 @@ type AuditLogEntry = { createdAt: string; }; +type BirdTimelineEvent = { + id: string; + birdId: string; + eventType: 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note'; + fromWorkspaceId: number | null; + toWorkspaceId: number | null; + fromWorkspaceName: string | null; + toWorkspaceName: string | null; + fromOwnerEmail: string | null; + toOwnerEmail: string | null; + locationLabel: string | null; + locationDetails: VerifiedLocationDetails | null; + note: string | null; + eventDate: string; + createdByUserId: string | null; + createdAt: string; +}; + +type BirdTimelineEventFormState = { + eventType: 'location_updated' | 'owner_changed' | 'manual_note'; + ownerChanged: boolean; + eventDate: string; + locationLabel: string; + locationDetails: VerifiedLocationDetails; + note: string; +}; + type IntegrationTokenFormState = { name: string; scope: IntegrationTokenScope; @@ -225,6 +277,8 @@ type BirdFormState = { motivators: string; demotivators: string; favoriteSnack: string; + locationLabel: string; + locationDetails: VerifiedLocationDetails; vetClinicName: string; vetClinicAddress: string; vetAccountNumber: string; @@ -273,6 +327,7 @@ type PublicBirdProfile = { name: string; favoriteSnack: string | null; gender: BirdGender; + hatchDay: string | null; dateOfBirth: string | null; photoDataUrl: string | null; }; @@ -374,7 +429,7 @@ type WeightDropAlert = { }; type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit'; -type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit'; +type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'timeline' | 'audit'; type DismissedAlertMap = Record; type PhotoCropState = { @@ -396,7 +451,7 @@ type PhotoDragState = { }; type AppPage = 'overview' | 'flock' | 'settings' | 'admin'; -type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'bird-import' | 'transfer'; +type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'bird-import' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; @@ -640,6 +695,123 @@ const parseBirdImportRows = (rows: Record[]): BirdImportPreview errors, }; }; + +const emptyVerifiedLocationDetails: VerifiedLocationDetails = { + label: '', + city: '', + region: '', + country: '', + countryCode: '', + latitude: null, + longitude: null, + precision: 'city', + provider: null, + providerPlaceId: '', +}; + +const emptyLocationSearchState: LocationSearchState = { + query: '', + results: [], + searching: false, + error: '', +}; + +const normalizeVerifiedLocationDetails = (details: Partial | null | undefined): VerifiedLocationDetails => ({ + ...emptyVerifiedLocationDetails, + ...details, + label: details?.label ?? '', + city: details?.city ?? '', + region: details?.region ?? '', + country: details?.country ?? '', + countryCode: details?.countryCode ?? '', + latitude: typeof details?.latitude === 'number' ? details.latitude : null, + longitude: typeof details?.longitude === 'number' ? details.longitude : null, + precision: details?.precision ?? 'city', + provider: details?.provider ?? null, + providerPlaceId: details?.providerPlaceId ?? '', +}); + +const formatVerifiedLocationLabel = (details: VerifiedLocationDetails) => + details.label?.trim() || [details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', '); + +type VerifiedLocationSearchFieldProps = { + label: string; + location: VerifiedLocationDetails; + fallbackLabel?: string | null; + searchState: LocationSearchState; + onSearchStateChange: (state: LocationSearchState) => void; + onSearch: () => void; + onSelect: (location: VerifiedLocationDetails) => void; + onClear: () => void; +}; + +const VerifiedLocationSearchField = ({ + label, + location, + fallbackLabel, + searchState, + onSearchStateChange, + onSearch, + onSelect, + onClear, +}: VerifiedLocationSearchFieldProps) => { + const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || ''; + const hasSelectedLocation = Boolean(selectedLabel); + + return ( +
+
+ {label} +
+ onSearchStateChange({ ...searchState, query: event.target.value, error: '' })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onSearch(); + } + }} + placeholder="Search city, region, or country" + /> + + {hasSelectedLocation ? ( + + ) : null} +
+
+ {searchState.error ? ( +

+ {searchState.error} +

+ ) : null} + {searchState.results.length ? ( +
+ {searchState.results.map((result) => { + const resultLabel = formatVerifiedLocationLabel(result); + return ( + + ); + })} +
+ ) : null} +
+ ); +}; + const emptyBirdForm: BirdFormState = { name: '', tagId: '', @@ -647,6 +819,8 @@ const emptyBirdForm: BirdFormState = { motivators: '', demotivators: '', favoriteSnack: '', + locationLabel: '', + locationDetails: emptyVerifiedLocationDetails, vetClinicName: '', vetClinicAddress: '', vetAccountNumber: '', @@ -661,6 +835,15 @@ const emptyBirdForm: BirdFormState = { publicProfileEnabled: false, }; +const emptyBirdTimelineEventForm: BirdTimelineEventFormState = { + eventType: 'location_updated', + ownerChanged: false, + eventDate: new Date().toISOString().slice(0, 10), + locationLabel: '', + locationDetails: emptyVerifiedLocationDetails, + note: '', +}; + const birdGenderOptions: BirdGender[] = ['female', 'female_dna', 'male', 'male_dna', 'unknown']; const emptyVeterinaryInfoForm: VeterinaryInfoFormState = { @@ -791,12 +974,14 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ motivators: parseBirdProfileList(bird.motivators).join('\n'), demotivators: parseBirdProfileList(bird.demotivators).join('\n'), favoriteSnack: bird.favoriteSnack ?? '', + locationLabel: bird.locationLabel ?? '', + locationDetails: normalizeVerifiedLocationDetails(bird.locationDetails), vetClinicName: bird.vetClinicName ?? '', vetClinicAddress: bird.vetClinicAddress ?? '', vetAccountNumber: bird.vetAccountNumber ?? '', vetDoctorName: bird.vetDoctorName ?? '', gender: bird.gender, - dateOfBirth: bird.dateOfBirth ?? '', + dateOfBirth: getBirdHatchDay(bird) ?? '', gotchaDay: bird.gotchaDay ?? '', chartColor: bird.chartColor, photoDataUrl: bird.photoDataUrl ?? '', @@ -947,6 +1132,358 @@ const formatAuditAction = (value: string) => .map((part) => part.charAt(0).toUpperCase() + part.slice(1).replace(/_/g, ' ')) .join(' '); +const formatBirdTimelineTitle = (event: BirdTimelineEvent) => { + if (event.eventType === 'profile_created') { + return 'Added to flock'; + } + + if (event.eventType === 'location_updated') { + return 'Location updated'; + } + + if (event.eventType === 'owner_changed') { + return 'Owner record changed'; + } + + if (event.eventType === 'manual_note') { + return 'Timeline note'; + } + + return 'Moved between flocks'; +}; + +const formatBirdTimelineDescription = (event: BirdTimelineEvent) => { + if (event.eventType === 'profile_created') { + return event.locationLabel ? `Location: ${event.locationLabel}` : `Flock: ${event.toWorkspaceName || 'Unknown flock'}`; + } + + if (event.eventType === 'location_updated') { + return `Location: ${event.locationLabel || 'Not recorded'}`; + } + + if (event.eventType === 'owner_changed') { + return event.locationLabel ? `Owner changed at ${event.locationLabel}` : 'Owner changed'; + } + + if (event.eventType === 'manual_note') { + return event.locationLabel ? `Note at ${event.locationLabel}` : 'General timeline note'; + } + + const fromLocation = event.fromWorkspaceName || 'Previous flock'; + const toLocation = event.toWorkspaceName || event.locationLabel || 'Current flock'; + return `${fromLocation} to ${toLocation}`; +}; + +const formatBirdTimelineSecondary = (event: BirdTimelineEvent) => { + if (event.note) { + return event.note; + } + + if (event.eventType === 'transferred') { + return 'FlockPal transfer recorded'; + } + + if (event.eventType === 'owner_changed') { + return 'Owner changed without owner name'; + } + + return ''; +}; + +const getBirdTimelineLocation = (event: BirdTimelineEvent) => + event.locationLabel || event.toWorkspaceName || event.fromWorkspaceName || 'Unknown location'; + +const getBirdTimelineEventDate = (event: BirdTimelineEvent) => event.eventDate || event.createdAt.slice(0, 10); + +const sortBirdTimelineEvents = (events: BirdTimelineEvent[]) => + [...events].sort((left, right) => { + const dateComparison = getBirdTimelineEventDate(right).localeCompare(getBirdTimelineEventDate(left)); + return dateComparison || right.createdAt.localeCompare(left.createdAt); + }); + +const TIMELINE_GRAPH_START_X = 52; +const TIMELINE_GRAPH_END_X = 588; + +type BirdTimelineGraphItem = { + id: string; + eventType: BirdTimelineEvent['eventType'] | 'hatch_date'; + date: string; + label: string; +}; + +type BirdTimelineGraphTick = { + id: string; + year: number; + date: string; + label: string; + isToday?: boolean; +}; + +type BirdTimelineGraphDomain = { + startTime: number; + endTime: number; +}; + +const getBirdHatchDay = (bird: Pick) => { + const hatchDay = bird.hatchDay?.trim(); + const dateOfBirth = bird.dateOfBirth?.trim(); + return hatchDay || dateOfBirth || null; +}; + +const getBirdTimelineGraphLabel = (event: BirdTimelineEvent) => { + if (event.eventType === 'location_updated') { + return event.locationLabel || 'Location updated'; + } + + if (event.eventType === 'owner_changed') { + return 'Owner changed'; + } + + if (event.eventType === 'transferred') { + return event.toWorkspaceName || 'Flock transfer'; + } + + if (event.eventType === 'manual_note') { + return event.locationLabel || 'Timeline note'; + } + + return event.locationLabel || 'Added to flock'; +}; + +const renderBirdTimelineGraphIcon = (item: BirdTimelineGraphItem) => { + const iconPath = + item.eventType === 'hatch_date' + ? "M480-120q-117 0-198.5-81.5T200-400q0-77 25.5-155t66-141.5Q332-760 382-800t98-40q49 0 98.5 40t90 103.5Q709-633 734.5-555T760-400q0 117-81.5 198.5T480-120Zm0-80q83 0 141.5-58.5T680-400q0-57-19.5-120t-49-116.5Q582-690 547-725t-67-35q-31 0-66.5 35t-65 88.5Q319-583 299.5-520T280-400q0 83 58.5 141.5T480-200Zm40-40q17 0 28.5-11.5T560-280q0-17-11.5-28.5T520-320q-50 0-85-35t-35-85q0-17-11.5-28.5T360-480q-17 0-28.5 11.5T320-440q0 83 58.5 141.5T520-240Zm-40-240Z" + : item.eventType === 'owner_changed' + ? "m770-120-56-56 63-64H610v-80h167l-63-64 56-56 160 160-160 160ZM400-360q56 0 101-27.5t71-72.5q-35-29-79-44.5T400-520q-49 0-93 15.5T228-460q26 45 71 72.5T400-360Zm0-200q33 0 56.5-23.5T480-640q0-33-23.5-56.5T400-720q-33 0-56.5 23.5T320-640q0 33 23.5 56.5T400-560Zm0 67Zm0 413Q239-217 159.5-334.5T80-552q0-150 96.5-239T400-880q127 0 223.5 89T720-552q0 9-.5 18.5T717-514h-81q2-10 3-19.5t1-18.5q0-109-69.5-178.5T400-800q-101 0-170.5 69.5T160-552q0 71 59 162.5T400-186q23-20 42.5-40t37.5-39l9 9 19.5 19.5q10.5 10.5 19 19.5l8.5 9q-29 31-63 63t-73 65Z" + : item.eventType === 'manual_note' + ? "M200-200h360v-200h200v-360H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v400L600-120H200Zm80-280v-80h200v80H280Zm0-160v-80h400v80H280Zm-80 360v-560 560Z" + : item.eventType === 'profile_created' + ? "m600-120-240-84-186 72q-20 8-37-4.5T120-170v-560q0-13 7.5-23t20.5-15l212-72 240 84 186-72q20-8 37 4.5t17 33.5v560q0 13-7.5 23T812-192l-212 72Zm-40-98v-468l-160-56v468l160 56Zm80 0 120-40v-474l-120 46v468Zm-440-10 120-46v-468l-120 40v474Zm440-458v468-468Zm-320-56v468-468Z" + : "M440-280q-7 0-12-4t-7-10q-14-42-34-70t-40-54q-20-26-33.5-54T300-540q0-58 41-99t99-41q58 0 99 41t41 99q0 40-13.5 68T533-418q-20 26-40 54t-34 70q-2 6-7 10t-12 4Zm0-112q9-14 18-26t17-23q23-30 34-50t11-49q0-33-23.5-56.5T440-620q-33 0-56.5 23.5T360-540q0 29 11 49t34 50q8 11 17 23t18 26Zm0-98q21 0 35.5-14.5T490-540q0-21-14.5-35.5T440-590q-21 0-35.5 14.5T390-540q0 21 14.5 35.5T440-490Zm0 370q-150 0-255-105T80-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T440-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T800-480v8l53-54 57 56-150 150-150-150 57-56 53 53v-7q0-116-82-198t-198-82q-116 0-198 82t-82 198q1 116 82.5 198T440-200q57 0 107-21.5t88-58.5l57 57q-49 48-113.5 75.5T440-120Zm0-420Z"; + + return ( + + ); +}; + +const compactTimelineLocationPartMap: Record = { + alabama: 'AL', + alaska: 'AK', + arizona: 'AZ', + arkansas: 'AR', + california: 'CA', + colorado: 'CO', + connecticut: 'CT', + delaware: 'DE', + florida: 'FL', + georgia: 'GA', + hawaii: 'HI', + idaho: 'ID', + illinois: 'IL', + indiana: 'IN', + iowa: 'IA', + kansas: 'KS', + kentucky: 'KY', + louisiana: 'LA', + maine: 'ME', + maryland: 'MD', + massachusetts: 'MA', + michigan: 'MI', + minnesota: 'MN', + mississippi: 'MS', + missouri: 'MO', + montana: 'MT', + nebraska: 'NE', + nevada: 'NV', + 'new hampshire': 'NH', + 'new jersey': 'NJ', + 'new mexico': 'NM', + 'new york': 'NY', + 'north carolina': 'NC', + 'north dakota': 'ND', + ohio: 'OH', + oklahoma: 'OK', + oregon: 'OR', + pennsylvania: 'PA', + 'rhode island': 'RI', + 'south carolina': 'SC', + 'south dakota': 'SD', + tennessee: 'TN', + texas: 'TX', + utah: 'UT', + vermont: 'VT', + virginia: 'VA', + washington: 'WA', + 'west virginia': 'WV', + wisconsin: 'WI', + wyoming: 'WY', + 'district of columbia': 'DC', + 'united states': 'US', + 'united states of america': 'US', +}; + +const compactTimelineLocationLabel = (label: string) => + label + .split(',') + .map((part) => { + const trimmedPart = part.trim(); + return compactTimelineLocationPartMap[trimmedPart.toLowerCase()] || trimmedPart; + }) + .filter(Boolean) + .join(', '); + +const getBirdTimelineGraphItems = (bird: Bird, events: BirdTimelineEvent[]): BirdTimelineGraphItem[] => { + const graphEvents: BirdTimelineGraphItem[] = sortBirdTimelineEvents(events) + .reverse() + .filter((event) => event.eventType !== 'profile_created') + .map((event) => ({ + id: event.id, + eventType: event.eventType, + date: getBirdTimelineEventDate(event), + label: compactTimelineLocationLabel(getBirdTimelineGraphLabel(event)), + })); + + const hatchDay = getBirdHatchDay(bird); + + if (!hatchDay) { + return graphEvents.slice(-8); + } + + const hatchItem: BirdTimelineGraphItem = { + id: `${bird.id}-hatch-date`, + eventType: 'hatch_date', + date: hatchDay, + label: 'Hatch Day', + }; + + return [ + hatchItem, + ...graphEvents.filter((event) => event.date >= hatchDay).slice(-7), + ].sort((left, right) => left.date.localeCompare(right.date)); +}; + +const getBirdTimelineGraphBranch = (item: BirdTimelineGraphItem, graphItems: BirdTimelineGraphItem[], index: number) => { + if (item.eventType === 'hatch_date') { + return { + placement: 'on-line', + distance: 0, + offset: 0, + angle: 0, + connectorLength: 0, + }; + } + + const itemX = getBirdTimelineGraphX(item, graphItems, index); + const closeItems = graphItems + .map((graphItem, graphItemIndex) => ({ + item: graphItem, + index: graphItemIndex, + x: getBirdTimelineGraphX(graphItem, graphItems, graphItemIndex), + })) + .filter((entry) => Math.abs(entry.x - itemX) < 112) + .sort((left, right) => left.x - right.x || left.item.date.localeCompare(right.item.date) || left.index - right.index); + const hasNearbyHatch = closeItems.some((entry) => entry.item.eventType === 'hatch_date'); + const closeEventItems = closeItems.filter((entry) => entry.item.eventType !== 'hatch_date'); + const closeEventIndex = Math.max(0, closeEventItems.findIndex((entry) => entry.item.id === item.id)); + const useCollisionLane = closeItems.length > 1; + const placement = useCollisionLane + ? hasNearbyHatch + ? closeEventIndex % 2 === 0 ? 'below' : 'above' + : closeEventIndex % 2 === 0 ? 'above' : 'below' + : index % 2 === 0 ? 'above' : 'below'; + const lane = useCollisionLane ? Math.floor(closeEventIndex / 2) : 0; + const distance = useCollisionLane ? 70 + lane * 56 : 42; + const offset = useCollisionLane ? 44 + lane * 14 : 0; + const connectorLength = Math.round(Math.sqrt(distance ** 2 + offset ** 2)); + + return { + placement, + distance, + offset, + angle: offset ? (placement === 'above' ? 1 : -1) * Math.round((Math.atan(offset / distance) * 180) / Math.PI) : 0, + connectorLength, + }; +}; + +const getLocalDateString = (date = new Date()) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const getBirdTimelineGraphDomain = (graphItems: BirdTimelineGraphItem[]): BirdTimelineGraphDomain | null => { + if (!graphItems.length) { + return null; + } + + const todayTime = parseDateValue(getLocalDateString()).getTime(); + const eventTimes = graphItems.map((entry) => parseDateValue(entry.date).getTime()); + return { + startTime: Math.min(...eventTimes), + endTime: Math.max(todayTime, ...eventTimes), + }; +}; + +const getTimelineGraphPosition = (date: string, domain: BirdTimelineGraphDomain | null, fallbackIndex = 0, fallbackCount = 1) => { + if (!domain || domain.startTime === domain.endTime) { + if (fallbackCount <= 1) { + return (TIMELINE_GRAPH_START_X + TIMELINE_GRAPH_END_X) / 2; + } + return TIMELINE_GRAPH_START_X + (fallbackIndex / (fallbackCount - 1)) * (TIMELINE_GRAPH_END_X - TIMELINE_GRAPH_START_X); + } + + const eventTime = parseDateValue(date).getTime(); + return TIMELINE_GRAPH_START_X + ((eventTime - domain.startTime) / (domain.endTime - domain.startTime)) * (TIMELINE_GRAPH_END_X - TIMELINE_GRAPH_START_X); +}; + +const getBirdTimelineGraphX = (item: BirdTimelineGraphItem, graphItems: BirdTimelineGraphItem[], index: number) => + getTimelineGraphPosition(item.date, getBirdTimelineGraphDomain(graphItems), index, graphItems.length); + +const getBirdTimelineGraphTicks = (graphItems: BirdTimelineGraphItem[]): BirdTimelineGraphTick[] => { + const domain = getBirdTimelineGraphDomain(graphItems); + if (!domain) { + return []; + } + + const { startTime, endTime } = domain; + const today = getLocalDateString(); + const startDate = new Date(startTime); + const endDate = new Date(endTime); + const startYear = startDate.getFullYear(); + const endYear = endDate.getFullYear(); + + const ticks: BirdTimelineGraphTick[] = Array.from({ length: endYear - startYear + 1 }, (_, index) => { + const year = startYear + index; + const yearStart = parseDateValue(`${year}-01-01`).getTime(); + const tickTime = Math.min(Math.max(yearStart, startTime), endTime); + return { + id: `timeline-year-${year}`, + year, + date: new Date(tickTime).toISOString().slice(0, 10), + label: `${year}`, + }; + }); + + if (!ticks.some((tick) => tick.date === today)) { + ticks.push({ + id: 'timeline-today', + year: parseDateValue(today).getFullYear(), + date: today, + label: 'Today', + isToday: true, + }); + } + + return ticks.sort((left, right) => left.date.localeCompare(right.date)); +}; + +const getBirdTimelineGraphTickX = (tick: BirdTimelineGraphTick, graphItems: BirdTimelineGraphItem[]) => + getTimelineGraphPosition(tick.date, getBirdTimelineGraphDomain(graphItems)); + const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); @@ -963,7 +1500,7 @@ const OVERVIEW_WINDOW_DAYS = 30; const OVERVIEW_HISTORY_DAYS = 425; const OVERVIEW_WIDTH = 520; const OVERVIEW_HEIGHT = 220; -const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 }; +const OVERVIEW_PADDING = { top: 48, right: 18, bottom: 36, left: 52 }; const PHOTO_MAX_BYTES = 900_000; const PHOTO_EXPORT_SIZES = [720, 600, 480]; const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66]; @@ -1132,7 +1669,7 @@ const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { } if (billingPlan === 'household_plus') { - return 'Permits 5 to 10 birds in the flock.'; + return 'Permits 5 to 9 birds in the flock.'; } if (billingPlan === 'household_macaw') { @@ -1144,11 +1681,11 @@ const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => { if (billingPlan === 'household_basic') { - return 'Conure (up to 4 birds)'; + return 'Conure (4 birds)'; } if (billingPlan === 'household_plus') { - return 'Indian Ringneck (5-10 birds)'; + return 'Indian Ringneck (5-9 birds)'; } if (billingPlan === 'household_macaw') { @@ -1186,7 +1723,7 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { } if (billingPlan === 'household_plus') { - return '10'; + return '9'; } if (billingPlan === 'household_macaw') { @@ -1591,6 +2128,8 @@ function App() { const [integrationTokens, setIntegrationTokens] = useState([]); const [flockNotes, setFlockNotes] = useState([]); const [auditLogEntries, setAuditLogEntries] = useState([]); + const [birdTimelineEvents, setBirdTimelineEvents] = useState([]); + const [birdTimelineEventForm, setBirdTimelineEventForm] = useState(emptyBirdTimelineEventForm); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [birds, setBirds] = useState([]); @@ -1634,6 +2173,11 @@ function App() { const [savingFlockNote, setSavingFlockNote] = useState(false); const [deletingFlockNoteId, setDeletingFlockNoteId] = useState(''); const [auditLogLoading, setAuditLogLoading] = useState(false); + const [birdTimelineLoading, setBirdTimelineLoading] = useState(false); + const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false); + const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null); + const [birdLocationSearch, setBirdLocationSearch] = useState(emptyLocationSearchState); + const [timelineLocationSearch, setTimelineLocationSearch] = useState(emptyLocationSearchState); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); @@ -1712,7 +2256,7 @@ function App() { authSession.user.email.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(), ); const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : ''; - const editingBird = useMemo( + const editingBird = useMemo( () => birds.find((bird) => bird.id === editingBirdId) ?? null, [birds, editingBirdId], ); @@ -1739,6 +2283,10 @@ function App() { useEffect(() => { setSelectedBirdTab('info'); + setBirdTimelineEvents([]); + setBirdTimelineEventForm(emptyBirdTimelineEventForm); + setBirdTimelineNotice(null); + setTimelineLocationSearch(emptyLocationSearchState); }, [selectedBirdId]); useEffect(() => { @@ -1750,46 +2298,6 @@ function App() { setEditingVeterinaryInfo(false); }, [selectedBird]); - useEffect(() => { - const selectedBirdId = selectedBird?.id; - - if (!selectedBirdId || !authToken || adoptionTransferCodes[selectedBirdId]) { - return; - } - - let canceled = false; - - const loadOpenTransferCode = async () => { - try { - const response = await apiFetch(`/birds/${selectedBirdId}/transfer-code`, authToken); - - if (!response.ok) { - return; - } - - const data = - (await readJsonSafely<{ - transferCode?: { - code?: string; - } | null; - }>(response)) ?? {}; - const code = data.transferCode?.code; - - if (!canceled && code) { - setAdoptionTransferCodes((current) => ({ ...current, [selectedBirdId]: code })); - } - } catch { - // Transfer codes are optional until a report/code is created. - } - }; - - void loadOpenTransferCode(); - - return () => { - canceled = true; - }; - }, [adoptionTransferCodes, authToken, selectedBird?.id]); - const overviewWindowStartDate = useMemo(() => { const startDate = new Date(); startDate.setHours(0, 0, 0, 0); @@ -2498,7 +3006,33 @@ function App() { }; void loadAuditLog(); - }, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]); + }, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]); + + useEffect(() => { + if (!authToken || selectedBirdTab !== 'timeline' || !selectedBird) { + return; + } + + const loadBirdTimeline = async () => { + try { + setBirdTimelineLoading(true); + const response = await apiFetch(`/birds/${selectedBird.id}/timeline`, authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load bird timeline.')); + } + + const data = (await readJsonSafely<{ events?: BirdTimelineEvent[] }>(response)) ?? {}; + setBirdTimelineEvents(sortBirdTimelineEvents(data.events ?? [])); + } catch (timelineError) { + setError(timelineError instanceof Error ? timelineError.message : 'Unable to load bird timeline.'); + } finally { + setBirdTimelineLoading(false); + } + }; + + void loadBirdTimeline(); + }, [authToken, selectedBird, selectedBirdTab]); useEffect(() => { if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { @@ -2648,6 +3182,7 @@ function App() { if (!editingBird) { setEditingBirdId(''); setBirdForm(emptyBirdForm); + setBirdLocationSearch(emptyLocationSearchState); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); @@ -2655,6 +3190,7 @@ function App() { } setBirdForm(toBirdForm(editingBird)); + setBirdLocationSearch(emptyLocationSearchState); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); @@ -2683,6 +3219,7 @@ function App() { setEditingBirdId(''); setBirdEditorOpen(true); setBirdForm(emptyBirdForm); + setBirdLocationSearch(emptyLocationSearchState); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); @@ -2690,6 +3227,42 @@ function App() { setActivePage('flock'); }; + const searchVerifiedLocations = async ( + searchState: LocationSearchState, + setSearchState: Dispatch>, + ) => { + const query = searchState.query.replace(/\s+/g, ' ').trim(); + + if (query.length < 3) { + setSearchState((current) => ({ ...current, error: 'Enter at least 3 characters to search.', results: [] })); + return; + } + + setSearchState((current) => ({ ...current, searching: true, error: '', results: [] })); + + try { + const response = await apiFetch(`/locations/search?q=${encodeURIComponent(query)}`, authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to search locations.')); + } + + const data = (await readJsonSafely<{ results?: VerifiedLocationDetails[] }>(response)) ?? {}; + setSearchState((current) => ({ + ...current, + searching: false, + results: (data.results ?? []).map(normalizeVerifiedLocationDetails), + error: data.results?.length ? '' : 'No matching city, region, or country found.', + })); + } catch (searchError) { + setSearchState((current) => ({ + ...current, + searching: false, + error: searchError instanceof Error ? searchError.message : 'Unable to search locations.', + })); + } + }; + const handleAuthSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -3253,6 +3826,7 @@ function App() { demotivators: profile.demotivators, favoriteSnack: profile.favoriteSnack, gender: profile.gender, + hatchDay: profile.dateOfBirth, dateOfBirth: profile.dateOfBirth, gotchaDay: profile.gotchaDay, chartColor: profile.chartColor || '#cb3a35', @@ -3345,10 +3919,11 @@ function App() { const method = isEditing ? 'PUT' : 'POST'; try { + const locationLabel = formatVerifiedLocationLabel(birdForm.locationDetails) || birdForm.locationLabel; const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, { method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(birdForm), + body: JSON.stringify({ ...birdForm, hatchDay: birdForm.dateOfBirth, locationLabel }), }); if (!response.ok) { @@ -3381,6 +3956,67 @@ function App() { } }; + const handleBirdTimelineEventSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!selectedBird || savingBirdTimelineEvent) { + return; + } + + setError(''); + setBirdTimelineNotice(null); + setSavingBirdTimelineEvent(true); + + try { + const eventType = birdTimelineEventForm.ownerChanged ? 'owner_changed' : birdTimelineEventForm.eventType; + const verifiedLocationLabel = formatVerifiedLocationLabel(birdTimelineEventForm.locationDetails); + const locationLabel = + birdTimelineEventForm.eventType === 'location_updated' + ? verifiedLocationLabel || birdTimelineEventForm.locationLabel.trim() || selectedBird.locationLabel || '' + : birdTimelineEventForm.locationLabel; + const note = birdTimelineEventForm.note.trim(); + + if (eventType !== 'owner_changed' && !locationLabel.trim() && !note) { + setBirdTimelineNotice({ + kind: 'error', + message: 'Add a location or note before adding this timeline item.', + }); + return; + } + + const response = await apiFetch(`/birds/${selectedBird.id}/timeline`, authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...birdTimelineEventForm, + eventType, + locationLabel, + note, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to add timeline item.')); + } + + const data = await readJsonSafely<{ event?: BirdTimelineEvent }>(response); + if (!data?.event) { + throw new Error('Unable to add timeline item.'); + } + + setBirdTimelineEvents((current) => sortBirdTimelineEvents([data.event!, ...current.filter((entry) => entry.id !== data.event!.id)])); + setBirdTimelineEventForm(emptyBirdTimelineEventForm); + setBirdTimelineNotice({ kind: 'success', message: 'Timeline item added.' }); + } catch (timelineError) { + setBirdTimelineNotice({ + kind: 'error', + message: timelineError instanceof Error ? timelineError.message : 'Unable to add timeline item.', + }); + } finally { + setSavingBirdTimelineEvent(false); + } + }; + const handleVeterinaryInfoSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -4241,7 +4877,7 @@ function App() { ['Species', selectedBird.species], ['Band/tag ID', selectedBird.tagId || 'Not recorded'], ['Sex', getBirdGenderLabel(selectedBird)], - ['Hatch day', formatDate(selectedBird.dateOfBirth)], + ['Hatch day', formatDate(getBirdHatchDay(selectedBird))], ['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'], ['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'], ]; @@ -4954,7 +5590,7 @@ function App() {
Hatch Day - {formatDate(publicProfile.dateOfBirth)} + {formatDate(getBirdHatchDay(publicProfile))}
Favorite treat @@ -5390,6 +6026,49 @@ function App() { {`${bird.name}: ${point.label}`} ))} + {points.length > 0 ? (() => { + const latestPoint = points[points.length - 1]; + const pointerRadius = 13; + const pointerCenterY = latestPoint.y - 21; + const pointerTop = pointerCenterY - pointerRadius; + const pointerPath = [ + `M ${latestPoint.x} ${latestPoint.y}`, + `C ${latestPoint.x - 2.5} ${latestPoint.y - 5}, ${latestPoint.x - pointerRadius} ${latestPoint.y - 13}, ${latestPoint.x - pointerRadius} ${pointerCenterY}`, + `A ${pointerRadius} ${pointerRadius} 0 1 1 ${latestPoint.x + pointerRadius} ${pointerCenterY}`, + `C ${latestPoint.x + pointerRadius} ${latestPoint.y - 13}, ${latestPoint.x + 2.5} ${latestPoint.y - 5}, ${latestPoint.x} ${latestPoint.y}`, + 'Z', + ].join(' '); + const clipPathId = `overview-bird-portrait-${bird.id}`; + + return ( + + + + + + + + {`${bird.name}'s latest weight: ${latestPoint.label}`} + + + ); + })() : null} ))} @@ -5529,6 +6208,12 @@ function App() { Pending rescues {adminSummary?.pendingRescues ?? '-'}
+ {(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw'] as const).map((billingPlan) => ( +
+ {formatBillingPlanName(billingPlan)} subscriptions + {adminSummary?.subscriptionsByPlan?.[billingPlan] ?? '-'} +
+ ))} @@ -5824,6 +6509,27 @@ function App() { ) : null} +
+

Location

+

Mappable location

+
+ searchVerifiedLocations(birdLocationSearch, setBirdLocationSearch)} + onSelect={(location) => { + const locationLabel = formatVerifiedLocationLabel(location); + setBirdForm({ ...birdForm, locationDetails: location, locationLabel }); + setBirdLocationSearch({ ...emptyLocationSearchState, query: locationLabel }); + }} + onClear={() => { + setBirdForm({ ...birdForm, locationDetails: emptyVerifiedLocationDetails, locationLabel: '' }); + setBirdLocationSearch(emptyLocationSearchState); + }} + />
Gender
@@ -6285,26 +6991,37 @@ function App() { aria-selected={selectedBirdTab === 'notes'} aria-label="Notes" title="Notes" - > - - - + - + +
@@ -6417,7 +7135,7 @@ function App() {
Hatch Day - {formatDate(selectedBird.dateOfBirth)} + {formatDate(getBirdHatchDay(selectedBird))}
Gotcha day @@ -6688,11 +7406,11 @@ function App() { {selectedBirdTab === 'vet' ? (
-
-
-

Veterinary

-

Clinic account

-
+
+
+

Veterinary

+

Clinic account

+
{!editingVeterinaryInfo ? (
)}
- -
- ) : null} + + + ) : null} - {selectedBirdTab === 'reports' ? ( -
-
+ {selectedBirdTab === 'reports' ? ( +
+

Reports

@@ -6967,11 +7685,206 @@ function App() { {adoptionReportError}

) : null} -
-
- ) : null} +
+
+ ) : null} - {selectedBirdTab === 'audit' ? ( + {selectedBirdTab === 'timeline' ? ( +
+
+
+
+

Timeline

+

{selectedBird.name} locations

+
+

{birdTimelineLoading ? 'Loading...' : `${birdTimelineEvents.length} events`}

+
+ {(() => { + const timelineGraphItems = getBirdTimelineGraphItems(selectedBird, birdTimelineEvents); + const timelineGraphTicks = getBirdTimelineGraphTicks(timelineGraphItems); + + return timelineGraphItems.length ? ( +
+
+
+ + {timelineGraphItems.map((timelineItem, index, graphItems) => { + const x = getBirdTimelineGraphX(timelineItem, graphItems, index); + const position = `${(x / 640) * 100}%`; + const branch = getBirdTimelineGraphBranch(timelineItem, graphItems, index); + + return ( +
+
+
+ {renderBirdTimelineGraphIcon(timelineItem)} +
+
+ {timelineItem.label} + {formatShortDate(timelineItem.date)} +
+
+ ); + })} +
+
+ ) : null; + })()} +
+ + + {birdTimelineEventForm.eventType !== 'owner_changed' ? ( + searchVerifiedLocations(timelineLocationSearch, setTimelineLocationSearch)} + onSelect={(location) => { + const locationLabel = formatVerifiedLocationLabel(location); + setBirdTimelineEventForm({ + ...birdTimelineEventForm, + locationDetails: location, + locationLabel, + }); + setTimelineLocationSearch({ ...emptyLocationSearchState, query: locationLabel }); + }} + onClear={() => { + setBirdTimelineEventForm({ + ...birdTimelineEventForm, + locationDetails: emptyVerifiedLocationDetails, + locationLabel: '', + }); + setTimelineLocationSearch(emptyLocationSearchState); + }} + /> + ) : null} + {birdTimelineEventForm.eventType === 'location_updated' ? ( + + ) : null} +