import crypto from 'crypto'; import { existsSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import cors from 'cors'; import dotenv from 'dotenv'; import express, { type NextFunction, type Request, type Response } from 'express'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import morgan from 'morgan'; import nodemailer, { type SendMailOptions } from 'nodemailer'; import Stripe from 'stripe'; import { z } from 'zod'; import { ensureSchema } from './db/schema.js'; import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js'; import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js'; import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js'; import { consumeMagicLinkToken, consumeOAuthState, createAuthSession as createAuthSessionRecord, createMagicLinkToken, createOAuthState, createUser, deleteAuthSession, deleteExpiredMagicLinkTokens, findUserByEmail, findUserByProviderAccount, linkAuthAccount, resolveAuth as resolveSessionAuth, resolveIntegrationTokenAuth, updateSessionWorkspace, updateUserName, } from './repositories/authRepository.js'; import { completePendingBirdTransfersForOwner, createBird, createBirdMilestoneReminderDelivery, createBirdTimelineEvent, createMedicationReminderDelivery, createBirdTransferCode, createMedicationForBird, createPendingBirdTransfer, findBirdsByBandId, createVetVisitForBird, createWeightForBird, deleteBird, deleteMedicationForBird, deleteVetVisitForBird, getBirdById, getBirdByPublicProfileCode, getOpenBirdTransferCode, listBirds, listBirdTimelineEvents, listDueBirdMilestoneReminders, listDueMedicationReminders, listMemorializedBirds, listMedicationAdministrationsForBird, listMedicationsForBird, listVetVisitsForBird, listWeightsForBird, memorializeBird, markBirdTransferCodeCompleted, transferBirdToWorkspace, updateBird, updateMemorialReminderPreference, updateMedicationForBird, updateWeightForBird, upsertMedicationAdministrationForBird, updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { createAuditLogEntry, createFlockNote, deleteFlockNote, listAuditLogEntries, listFlockNotes, } from './repositories/auditRepository.js'; import { deleteDailyEducation, deleteEducationQuestion, createEducationQuestion, getDailyEducationForDate, getEducationOptOut, listDailyEducationForAdmin, listDailyEducationQuestions, listEducationQuestionsForAdmin, updateEducationOptOut, updateEducationQuestion, upsertDailyEducation, } from './repositories/educationRepository.js'; import { buildBirdPhotoObjectKey, getImageExtensionFromContentType, getImageStorageProvider, getS3ImageStorageConfig, } from './storage/imageStorageConfig.js'; import { deleteS3Object, getSignedS3ObjectUrl, putS3Object } from './storage/s3Client.js'; import { cancelRescueVerificationRequest, claimWorkspaceInvites, createWorkspace, deleteWorkspaceMember, deleteWorkspaceIfEmpty, ensureDefaultWorkspaceForUser, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, getPlatformAdminSummary, getMembershipForUser, getNextWorkspaceId, getWorkspaceById, getWorkspaceTotalBirdCount, listOwnedWorkspacesByOwnerEmail, listRescueWorkspacesForAdmin, listMembershipsForUser, listWorkspaceNotificationEmails, listWorkspaceMembers, setWorkspaceStripeCustomerId, setWorkspaceStripeSubscription, setWorkspaceSubscriptionStatusByStripeSubscriptionId, updateRescueVerificationStatus, updateWorkspace, updateWorkspaceMemberRole, upsertWorkspaceMember, } from './repositories/workspaceRepository.js'; import type { AuthContext, AuditLogEntryRow, BillingInterval, BillingPlan, DailyEducationRow, EducationQuestionRow, BirdGender, BirdMilestoneReminderCandidateRow, BirdRow, BirdTimelineEventType, BirdTimelineEventRow, FlockNoteRow, IntegrationTokenRow, LostBirdMatchRow, MedicationAdministrationRow, MedicationReminderCandidateRow, MedicationRow, ProviderKey, RescueVerificationStatus, SubscriptionStatus, UserRow, VetVisitRow, WeightRow, WorkspaceMemberRow, WorkspaceRole, WorkspaceRow, WorkspaceType, } from './types.js'; dotenv.config(); declare global { namespace Express { interface Request { auth?: AuthContext; } } } const app = express(); const port = Number(process.env.PORT ?? 5000); const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000'; const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`; const sessionDays = 30; const trustProxy = process.env.TRUST_PROXY?.trim() ?? ''; const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000); const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; const milestoneReminderCheckIntervalMs = 60 * 60 * 1000; const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy'; if (trustProxy) { app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy); } const defaultAllowedOrigins = [ 'http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:8088', 'http://127.0.0.1:8088', 'https://flockpal.app', 'https://www.flockpal.app', ]; const allowedOrigins = Array.from( new Set( [process.env.FRONTEND_URL, process.env.FRONTEND_URLS] .filter(Boolean) .flatMap((value) => (value ?? '').split(',')) .map((origin) => origin.trim()) .filter(Boolean) .concat(defaultAllowedOrigins), ), ); const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/); const photoDataUrlSchema = z .string() .regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/) .max(1_500_000); const photoUrlSchema = z.string().trim().url().max(2000); const magicLinkRequestSchema = z.object({ name: z.string().trim().max(160).optional().or(z.literal('')), email: z.string().trim().email().max(255), redirectTo: z.string().trim().url().max(2000).optional().or(z.literal('')), }); const switchWorkspaceSchema = z.object({ workspaceId: z.coerce.number().int().positive(), }); const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']); const billingIntervalSchema = z.enum(['monthly', 'yearly']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const birdGenderSchema = z.enum(['unknown', 'male', 'female', 'male_dna', 'female_dna']); const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); const rescueOnboardingSchema = z.object({ name: z.string().trim().max(160).optional().or(z.literal('')), city: z.string().trim().max(120).optional().or(z.literal('')), state: z.string().trim().max(80).optional().or(z.literal('')), ein: z.string().trim().max(32).optional().or(z.literal('')), website: z.string().trim().url().max(2000).optional().or(z.literal('')), }); const workspaceSchema = z.object({ name: z.string().trim().min(1).max(160), workspaceType: workspaceTypeSchema, billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional(), rescueOnboarding: rescueOnboardingSchema.optional(), }); const createWorkspaceSchema = z.object({ name: z.string().trim().min(1).max(160), workspaceType: workspaceTypeSchema, billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional(), rescueOnboarding: rescueOnboardingSchema.optional(), }); const workspaceMemberSchema = z.object({ name: z.string().trim().min(1).max(160), email: z.string().trim().email().max(255), role: workspaceRoleSchema, }); const flockTransferSchema = z.object({ destinationOwnerEmail: z.string().trim().email().max(255), }); const lostBirdReportSchema = z.object({ tagId: z.string().trim().min(1).max(80), finderName: z.string().trim().max(160).optional().or(z.literal('')), finderEmail: z.string().trim().email().max(255).optional().or(z.literal('')), foundLocation: z.string().trim().max(255).optional().or(z.literal('')), message: z.string().trim().max(1000).optional().or(z.literal('')), }); const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/); const birdTransferCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{12,32}$/); const birdProfileListSchema = z .string() .trim() .max(1000) .refine( (value) => value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).length <= 3, 'Use no more than three list items.', ) .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('')), species: z.string().trim().min(1).max(120), 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(), photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(), notifyOnDob: z.boolean().optional(), notifyOnGotchaDay: z.boolean().optional(), publicProfileEnabled: z.boolean().optional(), }); const memorializeBirdSchema = z.object({ memorializedOn: dateStringSchema, memorialNote: z.string().trim().max(1000).optional().or(z.literal('')), notifyOnMemorialDay: z.boolean().optional(), }); 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, notes: z.string().trim().max(280).optional().or(z.literal('')), }); const vetVisitSchema = z.object({ visitedOn: dateStringSchema, clinicName: z.string().trim().min(1).max(160), reason: z.string().trim().min(1).max(160), notes: z.string().trim().max(1000).optional().or(z.literal('')), }); const medicationSchema = z .object({ name: z.string().trim().min(1).max(160), dosage: z.string().trim().min(1).max(160), frequency: z.enum(['once_daily', 'twice_daily', 'every_8_hours', 'every_6_hours', 'as_needed']), doseSchedule: z .array( z.object({ key: z.string().trim().min(1).max(80), label: z.string().trim().min(1).max(80), time: z.string().trim().regex(/^$|^\d{2}:\d{2}$/), }), ) .min(1) .max(8), route: z.string().trim().max(80).optional().or(z.literal('')), startDate: dateStringSchema, endDate: dateStringSchema.optional().or(z.literal('')), notes: z.string().trim().max(1000).optional().or(z.literal('')), remindersEnabled: z.boolean().optional(), }) .refine((value) => !value.endDate || value.endDate >= value.startDate, { message: 'End date must be on or after start date.', path: ['endDate'], }); const medicationAdministrationSchema = z.object({ administeredOn: dateStringSchema, administrationSlot: z.string().trim().min(1).max(80).default('dose-1'), status: z.enum(['administered', 'missed']), notes: z.string().trim().max(500).optional().or(z.literal('')), }); const flockNoteSchema = z.object({ birdId: z.string().uuid().optional().nullable().or(z.literal('')), body: z.string().trim().min(1).max(5000), }); const integrationTokenCreateSchema = z.object({ name: z.string().trim().min(1).max(160), scope: integrationTokenScopeSchema.default('read_write'), expiresInDays: z.coerce.number().int().min(1).max(3650).optional(), }); const educationQuestionSchema = z .object({ prompt: z.string().trim().min(1).max(500), options: z.array(z.string().trim().min(1).max(240)).min(2).max(4), correctAnswerIndex: z.coerce.number().int().min(0).max(3), explanation: z.string().trim().max(800).optional().or(z.literal('')), }) .refine((value) => value.correctAnswerIndex < value.options.length, { message: 'Correct answer must match one of the quiz options.', path: ['correctAnswerIndex'], }); const dailyEducationSchema = z.object({ publishDate: dateStringSchema, fact: z.string().trim().min(1).max(2000), }); const educationPreferenceSchema = z.object({ educationOptOut: z.boolean(), }); const emptyToNull = (value?: string) => { const trimmed = value?.trim() ?? ''; return trimmed ? trimmed : null; }; const unknownBandIdValues = new Set(['unknown', 'not recorded', 'n/a', 'na', 'none']); const normalizeBandId = (value?: string | null) => { const trimmed = value?.trim() ?? ''; return trimmed && !unknownBandIdValues.has(trimmed.toLowerCase()) ? trimmed : null; }; const normalizeEmail = (value: string) => value.trim().toLowerCase(); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const createSessionToken = () => crypto.randomBytes(32).toString('hex'); const photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken(); const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`; const createPublicProfileCode = () => crypto.randomBytes(9).toString('base64url'); const createRandomId = () => crypto.randomUUID(); const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url'); const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url'); const resolveBillingPlan = ( workspaceType: WorkspaceType, requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw', ) => { if (workspaceType === 'rescue') { return 'rescue_free' as const; } if (requestedPlan === 'household_hyacinth_macaw') { return 'household_hyacinth_macaw'; } if (requestedPlan === 'household_macaw') { return 'household_macaw'; } return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic'; }; const smtpHost = process.env.SMTP_HOST?.trim() ?? ''; const smtpPort = Number(process.env.SMTP_PORT ?? 587); const smtpSecure = process.env.SMTP_SECURE === 'true' || smtpPort === 465; const smtpUser = process.env.SMTP_USER?.trim() ?? ''; const smtpPass = process.env.SMTP_PASS?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app'; const rescueOnboardingWebhookUrl = process.env.RESCUE_ONBOARDING_WEBHOOK_URL?.trim() || 'https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee'; const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => { const nextUrl = new URL(url); if (!nextUrl.searchParams.has('billing')) { nextUrl.searchParams.set('billing', billingState); } return nextUrl.toString(); }; const stripeCheckoutSuccessUrl = withBillingRedirectState( process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`, 'success', ); const stripeCheckoutCancelUrl = withBillingRedirectState( process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`, 'cancelled', ); const stripePortalReturnUrl = withBillingRedirectState(process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl, 'portal'); const stripePriceByBillingPlanAndInterval: Partial, Partial>>> = { household_basic: { monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '', yearly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY?.trim() ?? '', }, household_plus: { monthly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() || '', yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '', }, household_macaw: { monthly: process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY?.trim() || '', yearly: process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY?.trim() ?? '', }, household_hyacinth_macaw: { monthly: process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW?.trim() || '', yearly: process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY?.trim() ?? '', }, }; const stripePriceEnvNamesByBillingPlanAndInterval: Record, Record> = { household_basic: { monthly: ['STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_CONURE'], yearly: ['STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY'], }, household_plus: { monthly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK'], yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'], }, household_macaw: { monthly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY'], yearly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY'], }, household_hyacinth_macaw: { monthly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW'], yearly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY'], }, }; const stripePricePlanLabels: Record, string> = { household_basic: 'Conure', household_plus: 'Indian Ringneck', household_macaw: 'African Grey', household_hyacinth_macaw: 'Hyacinth Macaw', }; const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null; const adminEmails = new Set( (process.env.ADMIN_EMAILS ?? '') .split(',') .map((email) => normalizeEmail(email)) .filter(Boolean), ); const mailTransport = smtpHost && smtpFromEmail ? nodemailer.createTransport({ host: smtpHost, port: smtpPort, secure: smtpSecure, auth: smtpUser && smtpPass ? { user: smtpUser, pass: smtpPass } : undefined, }) : null; const parseJwtPayload = >(token: string) => { const segments = token.split('.'); if (segments.length < 2) { throw new Error('Invalid token payload.'); } return JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8')) as T; }; const normalizeUser = (row: UserRow) => ({ id: row.id, email: row.email, name: row.name, createdAt: row.created_at, }); const normalizeWorkspace = (row: WorkspaceRow) => ({ id: row.id, name: row.name, workspaceType: row.workspace_type, billingEmail: row.billing_email, billingPlan: row.billing_plan, billingInterval: row.billing_interval, subscriptionStatus: row.subscription_status, stripeCustomerId: row.stripe_customer_id, stripeSubscriptionId: row.stripe_subscription_id, rescueVerificationStatus: row.rescue_verification_status, createdAt: row.created_at, updatedAt: row.updated_at, }); const normalizeAdminRescueWorkspace = ( row: WorkspaceRow & { bird_count: number; member_count: number; }, ) => ({ workspace: normalizeWorkspace(row), birdCount: Number(row.bird_count ?? 0), memberCount: Number(row.member_count ?? 0), }); const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({ id: row.id, workspaceId: row.workspace_id, userId: row.user_id, inviteEmail: row.invite_email, name: row.name, role: row.role, acceptedAt: row.accepted_at, createdAt: row.created_at, }); const normalizeEducationQuestion = (row: EducationQuestionRow) => ({ id: row.id, prompt: row.prompt, options: row.options, correctAnswerIndex: Number(row.correct_answer_index), explanation: row.explanation ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }); const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({ id: row.id, publishDate: row.publish_date, fact: row.fact, quizQuestions: questions.map(normalizeEducationQuestion), createdAt: row.created_at, updatedAt: row.updated_at, }); const signBirdPhotoAccessToken = (row: BirdRow) => { if (!row.photo_object_key) { return ''; } const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60; const payload = Buffer.from( JSON.stringify({ birdId: row.id, workspaceId: row.workspace_id, objectKey: row.photo_object_key, expiresAt, }), ).toString('base64url'); const signature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url'); return `${payload}.${signature}`; }; const verifyBirdPhotoAccessToken = (token: string) => { const [payload, signature] = token.split('.'); if (!payload || !signature) { return null; } const expectedSignature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url'); const signatureBuffer = Buffer.from(signature); const expectedSignatureBuffer = Buffer.from(expectedSignature); if (signatureBuffer.length !== expectedSignatureBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) { return null; } const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { birdId?: unknown; workspaceId?: unknown; objectKey?: unknown; expiresAt?: unknown; }; if ( typeof parsed.birdId !== 'string' || typeof parsed.workspaceId !== 'number' || typeof parsed.objectKey !== 'string' || typeof parsed.expiresAt !== 'number' || parsed.expiresAt < Math.floor(Date.now() / 1000) ) { return null; } return parsed as { birdId: string; workspaceId: number; objectKey: string; expiresAt: number; }; }; const getBirdPhotoUrl = (row: BirdRow) => { if (!row.photo_object_key) { return row.photo_data_url; } const s3Config = getS3ImageStorageConfig(); if (!s3Config) { return row.photo_data_url; } const photoUrl = new URL(`${backendBaseUrl}/api/birds/${row.id}/photo`); photoUrl.searchParams.set('token', signBirdPhotoAccessToken(row)); return photoUrl.toString(); }; const normalizeBird = (row: BirdRow) => ({ id: row.id, workspaceId: row.workspace_id, name: row.name, tagId: normalizeBandId(row.tag_id), species: row.species, 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, photoDataUrl: getBirdPhotoUrl(row), photoObjectKey: row.photo_object_key, photoContentType: row.photo_content_type, photoUpdatedAt: row.photo_updated_at, notifyOnDob: row.notify_on_dob, notifyOnGotchaDay: row.notify_on_gotcha_day, publicProfileCode: row.public_profile_code ?? null, publicProfileEnabled: row.public_profile_enabled ?? false, memorializedAt: row.memorialized_at, memorializedOn: row.memorialized_on, memorialNote: row.memorial_note, notifyOnMemorialDay: row.notify_on_memorial_day, createdAt: row.created_at, latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null, latestRecordedOn: row.latest_recorded_on, }); const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url'); 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), }); const normalizeWeight = (row: WeightRow) => ({ id: row.id, birdId: row.bird_id, weightGrams: Number(row.weight_grams), recordedOn: row.recorded_on, notes: row.notes, }); const normalizeVetVisit = (row: VetVisitRow) => ({ id: row.id, birdId: row.bird_id, visitedOn: row.visited_on, clinicName: row.clinic_name, reason: row.reason, notes: row.notes, }); const normalizeMedication = (row: MedicationRow) => ({ id: row.id, birdId: row.bird_id, name: row.name, dosage: row.dosage, frequency: row.frequency, doseSchedule: row.dose_schedule, route: row.route, startDate: row.start_date, endDate: row.end_date, notes: row.notes, remindersEnabled: row.reminders_enabled, }); const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({ id: row.id, medicationId: row.medication_id, birdId: row.bird_id, administeredOn: row.administered_on, administrationSlot: row.administration_slot, status: row.status, notes: row.notes, createdByUserId: row.created_by_user_id, createdAt: row.created_at, }); const normalizeFlockNote = (row: FlockNoteRow) => ({ id: row.id, workspaceId: row.workspace_id, birdId: row.bird_id, birdName: row.bird_name, title: row.title, body: row.body, createdByUserId: row.created_by_user_id, createdByName: row.created_by_name, createdAt: row.created_at, updatedAt: row.updated_at, }); const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({ id: row.id, workspaceId: row.workspace_id, userId: row.user_id, actorName: row.actor_name, actorEmail: row.actor_email, action: row.action, entityType: row.entity_type, entityId: row.entity_id, entityName: row.entity_name, details: row.details, 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, workspaceId: row.workspace_id, name: row.name, tokenPrefix: row.token_prefix, scope: row.scope, lastUsedAt: row.last_used_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, createdAt: row.created_at, }); const oauthProviders = { google: { providerKey: 'google' as const, displayName: 'Google', clientId: process.env.GOOGLE_CLIENT_ID ?? '', clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '', authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token', userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo', scopes: 'openid email profile', }, microsoft: { providerKey: 'microsoft' as const, displayName: 'Microsoft', clientId: process.env.MICROSOFT_CLIENT_ID ?? '', clientSecret: process.env.MICROSOFT_CLIENT_SECRET ?? '', authorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo', scopes: 'openid email profile User.Read', }, apple: { providerKey: 'apple' as const, displayName: 'Apple', clientId: process.env.APPLE_CLIENT_ID ?? '', clientSecret: process.env.APPLE_CLIENT_SECRET ?? '', authorizationEndpoint: 'https://appleid.apple.com/auth/authorize', tokenEndpoint: 'https://appleid.apple.com/auth/token', userinfoEndpoint: '', scopes: 'name email', }, }; app.disable('x-powered-by'); app.use(helmet({ crossOriginResourcePolicy: false })); app.use( cors({ exposedHeaders: ['X-FlockPal-Transfer-Code'], origin(origin, callback) { if (!origin || allowedOrigins.includes(origin)) { callback(null, true); return; } callback(new Error('Origin not allowed')); }, }), ); app.use( rateLimit({ windowMs: 15 * 60 * 1000, limit: 300, standardHeaders: true, legacyHeaders: false, }), ); const lostBirdReportLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: true, 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.' }); return; } const signature = req.headers['stripe-signature']; if (!signature) { res.status(400).json({ error: 'Missing Stripe signature.' }); return; } let event: Stripe.Event; try { event = getStripeClient().webhooks.constructEvent(req.body, signature, stripeWebhookSecret); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid Stripe webhook signature.' }); return; } try { if (event.type === 'checkout.session.completed') { const session = event.data.object as Stripe.Checkout.Session; const workspaceId = Number(session.metadata?.workspaceId ?? session.client_reference_id ?? 0); const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id; const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id; if (workspaceId && subscriptionId) { const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); const billingSelection = getBillingSelectionForStripeSubscription(subscription); await setWorkspaceStripeSubscription({ workspaceId, stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id), stripeSubscriptionId: subscription.id, subscriptionStatus: mapStripeSubscriptionStatus(subscription.status), billingPlan: billingSelection.billingPlan, billingInterval: billingSelection.billingInterval, }); } } if ( event.type === 'customer.subscription.created' || event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted' ) { const subscription = event.data.object as Stripe.Subscription; const workspaceId = Number(subscription.metadata?.workspaceId ?? 0); const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status); const billingSelection = getBillingSelectionForStripeSubscription(subscription); if (workspaceId) { await setWorkspaceStripeSubscription({ workspaceId, stripeCustomerId: customerId, stripeSubscriptionId: subscription.id, subscriptionStatus, billingPlan: billingSelection.billingPlan, billingInterval: billingSelection.billingInterval, }); } else { await setWorkspaceSubscriptionStatusByStripeSubscriptionId( subscription.id, subscriptionStatus, billingSelection.billingPlan, billingSelection.billingInterval, ); } } res.json({ received: true }); } catch (error) { console.error('Stripe webhook handling failed', error); res.status(500).json({ error: 'Unable to process Stripe webhook.' }); } }); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: false })); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); const requestMetrics = { startedAt: new Date().toISOString(), totalRequests: 0, totalErrors: 0, inFlightRequests: 0, totalDurationMs: 0, byStatus: {} as Record, byRoute: {} as Record, }; app.use((req: Request, res: Response, next: NextFunction) => { const startedAt = process.hrtime.bigint(); requestMetrics.totalRequests += 1; requestMetrics.inFlightRequests += 1; res.on('finish', () => { const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; requestMetrics.inFlightRequests -= 1; requestMetrics.totalDurationMs += durationMs; const statusBucket = `${Math.floor(res.statusCode / 100)}xx`; requestMetrics.byStatus[statusBucket] = (requestMetrics.byStatus[statusBucket] ?? 0) + 1; if (res.statusCode >= 500) { requestMetrics.totalErrors += 1; } const routeKey = `${req.method} ${req.route?.path ?? req.path}`; requestMetrics.byRoute[routeKey] = (requestMetrics.byRoute[routeKey] ?? 0) + 1; }); next(); }); const normalizeWorkspaceMembershipList = async (userId: string) => (await listMembershipsForUser(userId)).map((row) => ({ membership: normalizeWorkspaceMember(row), workspace: normalizeWorkspace({ id: row.workspace_id, name: row.workspace_name, workspace_type: row.workspace_type, billing_email: row.billing_email, billing_plan: row.billing_plan, billing_interval: row.billing_interval, subscription_status: row.subscription_status, stripe_customer_id: row.stripe_customer_id, stripe_subscription_id: row.stripe_subscription_id, rescue_verification_status: row.rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, }), })); const isAdminUser = (user: UserRow) => adminEmails.has(normalizeEmail(user.email)); const subscriptionAllowsWrite = (workspace: WorkspaceRow) => { if (workspace.workspace_type === 'rescue') { return workspace.rescue_verification_status === 'approved'; } return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing'; }; const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => { if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') { return status; } return 'none'; }; const getStripeClient = () => { if (!stripe) { throw new Error('Stripe is not configured.'); } return stripe; }; const getMostRelevantStripeSubscriptionForWorkspace = async (workspace: WorkspaceRow) => { const stripeClient = getStripeClient(); if (workspace.stripe_subscription_id) { return stripeClient.subscriptions.retrieve(workspace.stripe_subscription_id); } if (!workspace.stripe_customer_id) { return null; } const subscriptions = await stripeClient.subscriptions.list({ customer: workspace.stripe_customer_id, status: 'all', limit: 20, }); const matchingSubscription = [...subscriptions.data] .filter((subscription) => String(subscription.metadata?.workspaceId ?? '') === String(workspace.id)) .sort((left, right) => right.created - left.created)[0]; return matchingSubscription ?? null; }; const syncWorkspaceStripeBilling = async (workspaceId: number) => { const workspace = await getWorkspaceById(workspaceId); if (!workspace) { return null; } if (workspace.workspace_type === 'rescue') { throw new Error('Rescue flocks do not use Stripe billing.'); } const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace); if (!subscription) { return workspace; } const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; const billingSelection = getBillingSelectionForStripeSubscription(subscription); return ( (await setWorkspaceStripeSubscription({ workspaceId: workspace.id, stripeCustomerId: customerId, stripeSubscriptionId: subscription.id, subscriptionStatus: mapStripeSubscriptionStatus(subscription.status), billingPlan: billingSelection.billingPlan, billingInterval: billingSelection.billingInterval, })) ?? workspace ); }; const cancelWorkspaceStripeSubscription = async (workspace: WorkspaceRow) => { if (workspace.workspace_type === 'rescue' || (!workspace.stripe_subscription_id && !workspace.stripe_customer_id)) { return null; } const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace); if (!subscription || subscription.status === 'canceled') { return null; } await getStripeClient().subscriptions.cancel(subscription.id); return subscription.id; }; const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => { if (billingPlan === 'rescue_free') { throw new Error('Rescue flocks do not use Stripe billing.'); } const priceId = stripePriceByBillingPlanAndInterval[billingPlan]?.[billingInterval]?.trim() ?? ''; if (!priceId) { const planLabel = stripePricePlanLabels[billingPlan] ?? billingPlan; const envNames = stripePriceEnvNamesByBillingPlanAndInterval[billingPlan]?.[billingInterval] ?? []; const envHint = envNames.length > 0 ? ` Set ${envNames.join(' or ')} in the backend environment.` : ''; throw new Error(`Stripe price is not configured for ${planLabel} ${billingInterval}.${envHint}`); } return priceId; }; const getBillingSelectionForStripePrice = (priceId: string) => { for (const [billingPlan, intervals] of Object.entries(stripePriceByBillingPlanAndInterval)) { for (const [billingInterval, configuredPriceId] of Object.entries(intervals ?? {})) { if (configuredPriceId && configuredPriceId === priceId) { return { billingPlan: billingPlan as BillingPlan, billingInterval: billingInterval as BillingInterval, }; } } } return { billingPlan: null, billingInterval: null, }; }; const getBillingSelectionForStripeSubscription = (subscription: Stripe.Subscription) => { const priceId = subscription.items.data[0]?.price.id ?? ''; return getBillingSelectionForStripePrice(priceId); }; const createAuthSession = async (userId: string, activeWorkspaceId: number) => { const token = createSessionToken(); const tokenHash = hashToken(token); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + sessionDays); await createAuthSessionRecord(userId, activeWorkspaceId, tokenHash, expiresAt.toISOString()); return { token }; }; const buildSessionPayload = async (auth: AuthContext) => ({ user: normalizeUser(auth.user), activeWorkspace: normalizeWorkspace(auth.workspace), activeMembership: normalizeWorkspaceMember(auth.membership), workspaces: await normalizeWorkspaceMembershipList(auth.user.id), isAdmin: isAdminUser(auth.user), providers: Object.values(oauthProviders).map((provider) => ({ providerKey: provider.providerKey, displayName: provider.displayName, enabled: Boolean(provider.clientId && provider.clientSecret), })), }); const sendMagicLink = async ({ email, name, magicLinkUrl, }: { email: string; name: string | null; magicLinkUrl: string; }) => { if (!mailTransport) { console.log(`Magic sign-in link for ${email}: ${magicLinkUrl}`); return { delivered: false, previewUrl: magicLinkUrl, }; } await mailTransport.sendMail({ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, to: email, subject: 'Your FlockPal sign-in link', text: [ `Hi ${name || 'there'},`, '', 'Use this secure link to sign in to FlockPal:', magicLinkUrl, '', 'This link expires in 15 minutes and can only be used once.', ].join('\n'), html: `

Hi ${name || 'there'},

Use this secure link to sign in to FlockPal:

Sign in to FlockPal

This link expires in 15 minutes and can only be used once.

`, }); return { delivered: true, previewUrl: null, }; }; const escapeHtml = (value: string) => value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => { const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(date); const year = parts.find((part) => part.type === 'year')?.value ?? `${date.getUTCFullYear()}`; const month = parts.find((part) => part.type === 'month')?.value ?? `${date.getUTCMonth() + 1}`.padStart(2, '0'); const day = parts.find((part) => part.type === 'day')?.value ?? `${date.getUTCDate()}`.padStart(2, '0'); return `${year}-${month}-${day}`; }; const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => { const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(date); const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0'); const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0'); return `${hour === '24' ? '00' : hour}:${minute}`; }; const formatOrdinal = (value: number) => { const remainder = value % 100; if (remainder >= 11 && remainder <= 13) { return `${value}th`; } switch (value % 10) { case 1: return `${value}st`; case 2: return `${value}nd`; case 3: return `${value}rd`; default: return `${value}th`; } }; const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => { const sourceDate = reminder.reminder_type === 'hatch_day' ? reminder.date_of_birth : reminder.reminder_type === 'memorial_day' ? reminder.memorialized_on : reminder.gotcha_day; const sourceYear = Number(sourceDate?.slice(0, 4)); return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0; }; const getFlockPalLogoAttachment = () => { const logoPath = path.join(process.cwd(), 'assets', 'flockpal-logo.png'); if (!existsSync(logoPath)) { console.warn(`Unable to load FlockPal email logo from ${logoPath}`); return null; } return { filename: 'flockpal-logo.png', path: logoPath, cid: 'flockpal-logo', contentDisposition: 'inline' as const, }; }; const getEmailTrackPatternDataUrl = () => { const svg = ``; return `data:image/svg+xml,${encodeURIComponent(svg)}`; }; const parseDataImage = (dataUrl: string) => { const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl); if (!match) { return null; } return { contentType: match[1], content: Buffer.from(match[2], 'base64'), }; }; const isDataImageUrl = (value: string | null | undefined) => Boolean(value && value.startsWith('data:image/')); const resolveBirdPhotoStorage = async ({ birdId, workspaceId, photoDataUrl, existingBird, }: { birdId: string; workspaceId: number; photoDataUrl: string | null; existingBird?: BirdRow | null; }) => { if (!photoDataUrl) { return { photoDataUrl: null, photoObjectKey: null, photoContentType: null, photoUpdatedAt: null, objectKeyToDelete: existingBird?.photo_object_key ?? null, }; } if (!isDataImageUrl(photoDataUrl)) { if (existingBird?.photo_object_key) { return { photoDataUrl: null, photoObjectKey: existingBird.photo_object_key, photoContentType: existingBird.photo_content_type, photoUpdatedAt: existingBird.photo_updated_at, objectKeyToDelete: null, }; } return { photoDataUrl, photoObjectKey: null, photoContentType: null, photoUpdatedAt: null, objectKeyToDelete: existingBird?.photo_object_key ?? null, }; } const parsedImage = parseDataImage(photoDataUrl); if (!parsedImage) { throw new Error('Unable to process bird photo.'); } if (getImageStorageProvider() !== 's3') { return { photoDataUrl, photoObjectKey: null, photoContentType: null, photoUpdatedAt: null, objectKeyToDelete: existingBird?.photo_object_key ?? null, }; } const s3Config = getS3ImageStorageConfig(); if (!s3Config) { throw new Error('S3 image storage is enabled but not fully configured.'); } const extension = getImageExtensionFromContentType(parsedImage.contentType); const objectKey = buildBirdPhotoObjectKey({ workspaceId, birdId, extension }); await putS3Object({ config: s3Config, objectKey, content: parsedImage.content, contentType: parsedImage.contentType, }); return { photoDataUrl: null, photoObjectKey: objectKey, photoContentType: parsedImage.contentType, photoUpdatedAt: new Date().toISOString(), objectKeyToDelete: existingBird?.photo_object_key ?? null, }; }; const deleteBirdPhotoObjectIfNeeded = async (objectKey: string | null) => { if (!objectKey) { return; } const s3Config = getS3ImageStorageConfig(); if (!s3Config) { return; } try { await deleteS3Object({ config: s3Config, objectKey }); } catch (error) { console.warn(`Unable to delete old bird photo object ${objectKey}:`, error); } }; 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'); if (!existsSync(defaultPhotoPath)) { console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`); return null; } return { filename: 'yoda-default.png', path: defaultPhotoPath, cid: 'flockpal-default-bird-photo', contentDisposition: 'inline' as const, }; }; const sendRescueStatusNotification = async ({ workspace, ownerEmail, event, note, }: { workspace: WorkspaceRow; ownerEmail: string | null; event: 'created' | 'converted' | 'status_changed' | 'canceled'; note?: string | null; }) => { const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' '); const eventLabel = event === 'created' ? 'created' : event === 'converted' ? 'converted to rescue' : event === 'canceled' ? 'canceled rescue request' : 'status updated'; const subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`; const escapedWorkspaceName = escapeHtml(workspace.name); const escapedStatusLabel = escapeHtml(statusLabel); const escapedOwnerEmail = escapeHtml(ownerEmail ?? 'unknown'); const escapedBillingEmail = escapeHtml(workspace.billing_email ?? 'not set'); const lines = [ `Rescue flock: ${workspace.name}`, `Event: ${eventLabel}`, `Verification status: ${statusLabel}`, `Owner email: ${ownerEmail ?? 'unknown'}`, `Billing email: ${workspace.billing_email ?? 'not set'}`, `Flock ID: ${workspace.id}`, ]; const escapedNote = note ? escapeHtml(note) : null; if (note) { lines.push(`Note: ${note}`); } if (!mailTransport) { console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`); return { delivered: false }; } await mailTransport.sendMail({ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, to: rescueStatusNotificationEmail, subject, text: lines.join('\n'), html: `

A rescue flock was ${eventLabel}.

  • Rescue flock: ${escapedWorkspaceName}
  • Verification status: ${escapedStatusLabel}
  • Owner email: ${escapedOwnerEmail}
  • Billing email: ${escapedBillingEmail}
  • Flock ID: ${workspace.id}
${escapedNote ? `

Note: ${escapedNote}

` : ''} `, }); return { delivered: true }; }; type RescueOnboardingPayload = z.infer; const sendRescueOnboardingWebhook = async ({ action, workspaceId, flockName, ownerEmail, requestedByUserId, rescueOnboarding, }: { action: 'created' | 'converted'; workspaceId: number; flockName: string; ownerEmail: string; requestedByUserId: string; rescueOnboarding: RescueOnboardingPayload; }) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch(rescueOnboardingWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Name: rescueOnboarding.name, City: rescueOnboarding.city, State: rescueOnboarding.state, EIN: rescueOnboarding.ein, Website: rescueOnboarding.website, action, workspaceId, flockName, ownerEmail, requestedByUserId, submittedAt: new Date().toISOString(), }), signal: controller.signal, }); if (!response.ok) { throw new Error(`Rescue onboarding webhook returned ${response.status}`); } } finally { clearTimeout(timeout); } }; const trySendRescueOnboardingWebhook = async (payload: Parameters[0]) => { try { await sendRescueOnboardingWebhook(payload); return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown rescue onboarding webhook error.'; console.error(`Rescue onboarding webhook failed for workspace ${payload.workspaceId}:`, error); return `The rescue onboarding webhook failed and this rescue requires manual review. ${errorMessage}`; } }; const issueMagicLinkInvite = async ({ email, name, redirectTo = frontendBaseUrl, }: { email: string; name: string | null; redirectTo?: string; }) => { await deleteExpiredMagicLinkTokens(); const rawToken = createSessionToken(); const tokenHash = hashToken(rawToken); const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); await createMagicLinkToken(email, name, tokenHash, redirectTo, expiresAt); const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`); verifyUrl.searchParams.set('token', rawToken); return sendMagicLink({ email, name, magicLinkUrl: verifyUrl.toString(), }); }; const issueBirdTransferInvite = async ({ email, birdName, sourceWorkspaceName, redirectTo = frontendBaseUrl, }: { email: string; birdName: string; sourceWorkspaceName: string; redirectTo?: string; }) => { await deleteExpiredMagicLinkTokens(); const rawToken = createSessionToken(); const tokenHash = hashToken(rawToken); const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); await createMagicLinkToken(email, null, tokenHash, redirectTo, expiresAt); const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`); verifyUrl.searchParams.set('token', rawToken); const magicLinkUrl = verifyUrl.toString(); const subject = `${sourceWorkspaceName} sent you a bird transfer in FlockPal`; const text = [ 'Hi there,', '', `${sourceWorkspaceName} wants to transfer ${birdName} to your FlockPal account.`, 'Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.', magicLinkUrl, '', 'This link expires in 15 minutes and can only be used once.', ].join('\n'); if (!mailTransport) { console.log(`Bird transfer invite for ${email}: ${magicLinkUrl}`); return { delivered: false, previewUrl: magicLinkUrl, }; } await mailTransport.sendMail({ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, to: email, subject, text, html: `

Hi there,

${escapeHtml(sourceWorkspaceName)} wants to transfer ${escapeHtml(birdName)} to your FlockPal account.

Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.

Accept bird transfer in FlockPal

This link expires in 15 minutes and can only be used once.

`, }); return { delivered: true, previewUrl: null, }; }; const sendLostBirdReportNotification = async ({ bird, recipients, report, }: { bird: LostBirdMatchRow; recipients: string[]; report: z.infer; }) => { const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean))); if (!uniqueRecipients.length) { return { delivered: false }; } const finderName = emptyToNull(report.finderName) ?? 'Not provided'; const finderEmail = emptyToNull(report.finderEmail) ?? 'Not provided'; const foundLocation = emptyToNull(report.foundLocation) ?? 'Not provided'; const message = emptyToNull(report.message) ?? 'Not provided'; const subject = `Possible found bird report for ${bird.name}`; const lines = [ `A possible found bird report was submitted for ${bird.name}.`, '', `Band ID: ${bird.tag_id ?? 'Not recorded'}`, `Species: ${bird.species}`, `Flock: ${bird.workspace_name}`, '', `Finder name: ${finderName}`, `Finder email: ${finderEmail}`, `Found location: ${foundLocation}`, `Message: ${message}`, '', 'FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.', ]; if (!mailTransport) { console.log(`Found bird report for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`); return { delivered: false }; } await mailTransport.sendMail({ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, to: smtpFromEmail, bcc: uniqueRecipients, replyTo: emptyToNull(report.finderEmail) ?? undefined, subject, text: lines.join('\n'), html: `

A possible found bird report was submitted for ${escapeHtml(bird.name)}.

  • Band ID: ${escapeHtml(bird.tag_id ?? 'Not recorded')}
  • Species: ${escapeHtml(bird.species)}
  • Flock: ${escapeHtml(bird.workspace_name)}
  • Finder name: ${escapeHtml(finderName)}
  • Finder email: ${escapeHtml(finderEmail)}
  • Found location: ${escapeHtml(foundLocation)}
  • Message: ${escapeHtml(message)}

FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.

`, }); return { delivered: true }; }; const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidateRow) => { const yearCount = getMilestoneYearCount(reminder); const anniversaryLabel = yearCount > 0 ? formatOrdinal(yearCount) : ''; if (reminder.reminder_type === 'hatch_day') { return { subject: `Happy Hatch Day, ${reminder.name}!`, eyebrow: 'Hatch Day', headline: `Happy Hatch Day, ${reminder.name}!`, eventName: 'Hatch Day', intro: yearCount > 0 ? `From our flock to yours, wishing ${reminder.name} a happy Hatch Day! ${reminder.name} is ${yearCount} year${yearCount === 1 ? '' : 's'} old today.` : `${reminder.name} has a Hatch Day today.`, body: 'Cue the favorite snacks, extra head scritches if approved, and one tiny spotlight for the bird of the day.', milestoneLabel: yearCount > 0 ? `${yearCount} year${yearCount === 1 ? '' : 's'} old` : 'Hatch Day on file', }; } if (reminder.reminder_type === 'memorial_day') { return { subject: `Remembering ${reminder.name} today`, eyebrow: 'Memorial Day', headline: `Remembering ${reminder.name}`, eventName: 'Memorial Day', intro: yearCount > 0 ? `From our flock to yours, holding ${reminder.name}'s memory close on this ${formatOrdinal(yearCount)} memorial day.` : `From our flock to yours, holding ${reminder.name}'s memory close today.`, body: reminder.memorial_note ? reminder.memorial_note : 'A quiet moment for the feathers, songs, routines, and happy memories that still stay with you.', milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Memorial Day` : 'Memorial Day on file', }; } return { subject: `It's ${reminder.name}'s Gotcha Day!`, eyebrow: 'Gotcha Day', headline: `It's ${reminder.name}'s Gotcha Day!`, eventName: 'Gotcha Day', intro: yearCount > 0 ? `From our flock to yours, wishing ${reminder.name} a happy Gotcha Day! ${reminder.name} joined the flock ${yearCount} year${yearCount === 1 ? '' : 's'} ago today.` : `${reminder.name} has a Gotcha Day today.`, body: 'A good day to remember the first ride home, the first brave step up, and every happy moment spent together.', milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Gotcha Day` : 'Gotcha Day on file', }; }; const formatReminderDoseTime = (time: string) => { const [rawHour, rawMinute] = time.split(':'); const hour = Number(rawHour); const minute = rawMinute ?? '00'; if (Number.isNaN(hour)) { return time; } const period = hour >= 12 ? 'PM' : 'AM'; const displayHour = hour % 12 || 12; return `${displayHour}:${minute} ${period}`; }; const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => { const doseTime = formatReminderDoseTime(reminder.administration_time); const slotLabel = reminder.administration_label || 'Dose'; const route = reminder.route ? ` by ${reminder.route}` : ''; return { subject: `${reminder.medication_name} reminder for ${reminder.name}`, eyebrow: 'Medication Reminder', headline: `${slotLabel} time for ${reminder.name}`, intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`, body: `Dose: ${reminder.dosage}${route}.`, detailLabel: `${slotLabel} at ${doseTime}`, }; }; const sendBirdMilestoneReminderNotification = async ({ reminder, recipients, }: { reminder: BirdMilestoneReminderCandidateRow; recipients: string[]; }) => { const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean))); if (!uniqueRecipients.length) { return { delivered: false }; } const copy = buildBirdMilestoneReminderCopy(reminder); const attachments: NonNullable = []; const logoAttachment = getFlockPalLogoAttachment(); const trackPatternDataUrl = getEmailTrackPatternDataUrl(); const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null; const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment(); const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : ''; if (logoAttachment) { attachments.push(logoAttachment); } if (uploadedBirdPhoto) { attachments.push({ filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`, content: uploadedBirdPhoto.content, contentType: uploadedBirdPhoto.contentType, cid: birdPhotoCid, contentDisposition: 'inline', }); } else if (defaultBirdPhoto) { attachments.push(defaultBirdPhoto); } const birdPhotoHtml = birdPhotoCid ? `${escapeHtml(reminder.name)}` : `
${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}
`; const lines = [ copy.headline, '', copy.intro, copy.body, '', `Bird: ${reminder.name}`, `Species: ${reminder.species}`, `${copy.eventName}: ${copy.milestoneLabel}`, '', `Open FlockPal: ${frontendBaseUrl}`, ]; if (!mailTransport) { console.log(`Bird milestone reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`); return { delivered: false }; } await mailTransport.sendMail({ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, to: smtpFromEmail, bcc: uniqueRecipients, subject: copy.subject, text: lines.join('\n'), attachments, html: `
${ logoAttachment ? 'FlockPal' : 'FlockPal' }
${birdPhotoHtml}

${escapeHtml(copy.headline)}

${escapeHtml(copy.intro)}

${escapeHtml(copy.body)}

Open FlockPal

`, }); return { delivered: true }; }; const sendMedicationReminderNotification = async ({ reminder, recipients, }: { reminder: MedicationReminderCandidateRow; recipients: string[]; }) => { const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean))); if (!uniqueRecipients.length) { return { delivered: false }; } const copy = buildMedicationReminderCopy(reminder); const attachments: NonNullable = []; const logoAttachment = getFlockPalLogoAttachment(); const trackPatternDataUrl = getEmailTrackPatternDataUrl(); const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null; const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment(); const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : ''; if (logoAttachment) { attachments.push(logoAttachment); } if (uploadedBirdPhoto) { attachments.push({ filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`, content: uploadedBirdPhoto.content, contentType: uploadedBirdPhoto.contentType, cid: birdPhotoCid, contentDisposition: 'inline', }); } else if (defaultBirdPhoto) { attachments.push(defaultBirdPhoto); } const birdPhotoHtml = birdPhotoCid ? `${escapeHtml(reminder.name)}` : `
${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}
`; const medicationNotesHtml = reminder.medication_notes ? `

Medication notes: ${escapeHtml(reminder.medication_notes)}

` : ''; const lines = [ copy.headline, '', copy.intro, copy.body, '', `Bird: ${reminder.name}`, `Medication: ${reminder.medication_name}`, `Dose: ${reminder.dosage}`, `Scheduled dose: ${copy.detailLabel}`, reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '', '', `Open FlockPal: ${frontendBaseUrl}`, ].filter(Boolean); if (!mailTransport) { console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`); return { delivered: false }; } await mailTransport.sendMail({ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, to: smtpFromEmail, bcc: uniqueRecipients, subject: copy.subject, text: lines.join('\n'), attachments, html: `
${ logoAttachment ? 'FlockPal' : 'FlockPal' }
${birdPhotoHtml}

${escapeHtml(copy.eyebrow)}

${escapeHtml(copy.headline)}

${escapeHtml(copy.intro)}

${escapeHtml(copy.body)}

Schedule: ${escapeHtml(copy.detailLabel)}

${medicationNotesHtml}

Open FlockPal

`, }); return { delivered: true }; }; export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => { const reminders = await listDueBirdMilestoneReminders(runDate); let sent = 0; let skipped = 0; let failed = 0; for (const reminder of reminders) { try { const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id); const result = await sendBirdMilestoneReminderNotification({ reminder, recipients }); if (!result.delivered) { skipped += 1; continue; } const delivery = await createBirdMilestoneReminderDelivery({ birdId: reminder.id, workspaceId: reminder.workspace_id, reminderType: reminder.reminder_type, reminderYear: reminder.reminder_year, deliveredOn: runDate, }); if (delivery) { sent += 1; } else { skipped += 1; } } catch (error) { failed += 1; console.error(`Unable to send ${reminder.reminder_type} reminder for bird ${reminder.id}`, error); } } return { runDate, checked: reminders.length, sent, skipped, failed, }; }; export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => { const reminders = await listDueMedicationReminders(runDate, currentTime); let sent = 0; let skipped = 0; let failed = 0; for (const reminder of reminders) { try { const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id); const result = await sendMedicationReminderNotification({ reminder, recipients }); if (!result.delivered) { skipped += 1; continue; } const delivery = await createMedicationReminderDelivery({ medicationId: reminder.medication_id, birdId: reminder.id, workspaceId: reminder.workspace_id, scheduledOn: runDate, administrationSlot: reminder.administration_slot, }); if (delivery) { sent += 1; } else { skipped += 1; } } catch (error) { failed += 1; console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error); } } return { runDate, currentTime, checked: reminders.length, sent, skipped, failed, }; }; let lastMilestoneReminderRunDate = ''; let lastMedicationReminderRunKey = ''; export const startBirdMilestoneReminderScheduler = () => { if (!milestoneRemindersEnabled) { console.log('Bird milestone reminders are disabled.'); return; } const runIfNeeded = async () => { const runDate = getDateInTimeZone(); if (lastMilestoneReminderRunDate === runDate) { return; } lastMilestoneReminderRunDate = runDate; const job = await enqueueBirdMilestoneReminderJob(runDate); console.log(`Bird milestone reminder job queued for ${runDate}: id=${job.id ?? 'unknown'}`); }; setTimeout(() => { void runIfNeeded().catch((error) => { lastMilestoneReminderRunDate = ''; console.error('Bird milestone reminder scheduler failed', error); }); }, 15_000); setInterval(() => { void runIfNeeded().catch((error) => { lastMilestoneReminderRunDate = ''; console.error('Bird milestone reminder scheduler failed', error); }); }, milestoneReminderCheckIntervalMs); }; export const startMedicationReminderScheduler = () => { if (!medicationRemindersEnabled) { console.log('Medication reminders are disabled.'); return; } const runIfNeeded = async () => { const now = new Date(); const runDate = getDateInTimeZone(now); const currentTime = getTimeInTimeZone(now); const runKey = `${runDate}-${currentTime.slice(0, 2)}`; if (lastMedicationReminderRunKey === runKey) { return; } lastMedicationReminderRunKey = runKey; const job = await enqueueMedicationReminderJob(runDate, currentTime); console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`); }; setTimeout(() => { void runIfNeeded().catch((error) => { lastMedicationReminderRunKey = ''; console.error('Medication reminder scheduler failed', error); }); }, 30_000); setInterval(() => { void runIfNeeded().catch((error) => { lastMedicationReminderRunKey = ''; console.error('Medication reminder scheduler failed', error); }); }, milestoneReminderCheckIntervalMs); }; const readBearerToken = (authorizationHeader?: string) => { if (!authorizationHeader) { return ''; } const [scheme, token] = authorizationHeader.split(' '); return scheme?.toLowerCase() === 'bearer' && token ? token.trim() : ''; }; const resolveAnyAuth = async (token: string) => { if (!token) { return null; } return (await resolveSessionAuth(hashToken(token), token)) ?? resolveIntegrationTokenAuth(hashToken(token), token); }; const requireAuth = async (req: Request, res: Response, next: NextFunction) => { try { const token = readBearerToken(req.headers.authorization); const auth = await resolveAnyAuth(token); if (!auth) { res.status(401).json({ error: 'Authentication required.' }); return; } req.auth = auth; next(); } catch (error) { next(error); } }; const requireSessionAuth = (req: Request, res: Response, next: NextFunction) => { if (!req.auth) { res.status(401).json({ error: 'Authentication required.' }); return; } if (req.auth.authType !== 'session') { res.status(403).json({ error: 'This endpoint requires a browser session instead of an integration token.' }); return; } next(); }; const requireWriteAccess = (req: Request, res: Response, next: NextFunction) => { if (req.auth?.authType === 'integration_token' && req.auth.integrationToken?.scope !== 'read_write') { res.status(403).json({ error: 'That integration token is read-only.' }); return; } if (req.auth?.authType === 'session' && isAdminUser(req.auth.user)) { next(); return; } if (req.auth && !subscriptionAllowsWrite(req.auth.workspace)) { res.status(402).json({ error: req.auth.workspace.workspace_type === 'rescue' ? 'This rescue flock is read-only until FlockPal verifies it.' : 'This flock is read-only until the subscription is restored.', code: 'workspace_read_only', }); return; } next(); }; const requireAdmin = (req: Request, res: Response, next: NextFunction) => { if (!req.auth) { res.status(401).json({ error: 'Authentication required.' }); return; } if (!isAdminUser(req.auth.user)) { res.status(403).json({ error: 'Admin access required.' }); return; } next(); }; const requireWorkspaceRole = (allowedRoles: WorkspaceRole[]) => (req: Request, res: Response, next: NextFunction) => { if (!req.auth) { res.status(401).json({ error: 'Authentication required.' }); return; } if (!allowedRoles.includes(req.auth.membership.role)) { res.status(403).json({ error: 'You do not have permission for that action.' }); return; } next(); }; const ensureBirdWritable = (bird: BirdRow, res: Response) => { if (!bird.memorialized_at) { return true; } res.status(409).json({ error: 'This bird has been memorialized and is read-only.', code: 'bird_memorialized', }); return false; }; const writeAuditLog = async ( auth: AuthContext, action: string, entityType: string, entityId?: string | null, entityName?: string | null, details?: Record, ) => { try { await createAuditLogEntry({ workspaceId: auth.workspace.id, auth, action, entityType, entityId, entityName, details, }); } catch (error) { console.error('Unable to write audit log entry', error); } }; 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', (_req: Request, res: Response) => { res.json({ ok: true }); }); app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { const memoryUsage = process.memoryUsage(); const averageDurationMs = requestMetrics.totalRequests > 0 ? requestMetrics.totalDurationMs / requestMetrics.totalRequests : 0; try { const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts(); const medicationReminderQueueCounts = await getMedicationReminderQueueCounts(); res.json({ startedAt: requestMetrics.startedAt, uptimeSeconds: Math.round(process.uptime()), requests: { total: requestMetrics.totalRequests, inFlight: requestMetrics.inFlightRequests, errors: requestMetrics.totalErrors, averageDurationMs: Number(averageDurationMs.toFixed(2)), byStatus: requestMetrics.byStatus, byRoute: requestMetrics.byRoute, }, memory: { rss: memoryUsage.rss, heapTotal: memoryUsage.heapTotal, heapUsed: memoryUsage.heapUsed, external: memoryUsage.external, arrayBuffers: memoryUsage.arrayBuffers, }, queues: { birdMilestoneReminders: birdMilestoneReminderQueueCounts, medicationReminders: medicationReminderQueueCounts, }, }); } catch (error) { next(error); } }); app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, res: Response) => { const parsed = lostBirdReportSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid found bird report', details: parsed.error.flatten() }); return; } try { const matches = await findBirdsByBandId(parsed.data.tagId); let deliveredCount = 0; for (const bird of matches) { try { const recipients = await listWorkspaceNotificationEmails(bird.workspace_id); const delivery = await sendLostBirdReportNotification({ bird, recipients, report: parsed.data, }); if (delivery.delivered) { deliveredCount += 1; } } catch (error) { console.error('Lost bird notification failed', error); } } if (!matches.length) { res.json({ status: 'not_found', message: 'That band ID is not currently in the FlockPal system.', }); return; } if (deliveredCount > 0) { res.json({ status: 'contacted', message: 'A matching bird was found and the flock contacts were notified.', }); return; } res.status(503).json({ status: 'not_contacted', error: 'A matching bird was found, but FlockPal could not notify the flock right now. Please contact FlockPal support.', }); } catch (error) { console.error('Lost bird report handling failed', error); res.status(500).json({ error: 'Unable to process this found bird report right now.' }); } }); app.get('/api/public/birds/:publicProfileCode', async (req: Request, res: Response, next: NextFunction) => { const parsed = publicProfileCodeSchema.safeParse(req.params.publicProfileCode); if (!parsed.success) { res.status(404).json({ error: 'Public bird profile not found.' }); return; } try { const bird = await getBirdByPublicProfileCode(parsed.data); if (!bird) { res.status(404).json({ error: 'Public bird profile not found.' }); return; } res.json({ bird: normalizePublicBirdProfile(bird) }); } catch (error) { next(error); } }); app.get('/api/auth/providers', (_req: Request, res: Response) => { res.json({ providers: Object.values(oauthProviders).map((provider) => ({ providerKey: provider.providerKey, displayName: provider.displayName, enabled: Boolean(provider.clientId && provider.clientSecret), })), }); }); app.post('/api/auth/register', (_req: Request, res: Response) => { res.status(410).json({ error: 'Password-based registration is disabled. Use a magic link or an identity provider.' }); }); app.post('/api/auth/login', (_req: Request, res: Response) => { res.status(410).json({ error: 'Password-based sign-in is disabled. Use a magic link or an identity provider.' }); }); app.post('/api/auth/magic-link/request', async (req: Request, res: Response, next: NextFunction) => { const parsed = magicLinkRequestSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid magic link payload', details: parsed.error.flatten() }); return; } const email = normalizeEmail(parsed.data.email); const name = emptyToNull(parsed.data.name); const redirectTo = parsed.data.redirectTo || frontendBaseUrl; try { const delivery = await issueMagicLinkInvite({ email, name, redirectTo, }); res.status(202).json({ ok: true, message: 'If that address can sign in, a magic link is on the way.', previewUrl: delivery.previewUrl, delivery: delivery.delivered ? 'email' : 'preview', }); } catch (error) { next(error); } }); app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => { const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : ''; if (!rawToken) { res.status(400).send('Missing magic link token.'); return; } try { const magicLink = await consumeMagicLinkToken(hashToken(rawToken)); if (!magicLink) { res.status(400).send('That sign-in link is invalid or expired.'); return; } let user = await findUserByEmail(magicLink.email); if (!user) { user = await createUser(magicLink.email, magicLink.name || magicLink.email.split('@')[0] || 'FlockPal User'); } else if (magicLink.name && !user.name.trim()) { user = await updateUserName(user.id, magicLink.name); } await claimWorkspaceInvites(user!); const receivingWorkspaceId = await ensureDefaultWorkspaceForUser(user!); const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId); const memberships = await normalizeWorkspaceMembershipList(user!.id); const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId; const { token } = await createAuthSession(user!.id, activeWorkspaceId); const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl); redirectUrl.searchParams.set('auth_token', token); res.redirect(redirectUrl.toString()); } catch (error) { next(error); } }); app.post('/api/auth/logout', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { await deleteAuthSession(req.auth!.session.id); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/auth/session', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { res.json({ token: req.auth?.token, session: await buildSessionPayload(req.auth!), }); } catch (error) { next(error); } }); app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { const parsed = switchWorkspaceSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid flock selection payload', details: parsed.error.flatten() }); return; } try { const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId); if (!membership) { res.status(403).json({ error: 'You do not have access to that flock.' }); return; } await updateSessionWorkspace(req.auth!.session.id, parsed.data.workspaceId); const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token); if (!updatedAuth) { throw new Error('Unable to reload session.'); } res.json({ token: req.auth!.token, session: await buildSessionPayload(updatedAuth), }); } catch (error) { next(error); } }); app.get('/api/auth/oauth/:provider/start', async (req: Request, res: Response, next: NextFunction) => { const providerKey = req.params.provider as ProviderKey; const provider = oauthProviders[providerKey]; if (!provider) { res.status(404).json({ error: 'Unknown authentication provider.' }); return; } if (!provider.clientId || !provider.clientSecret) { res.status(400).json({ error: `${provider.displayName} login is not configured.` }); return; } try { const stateId = createRandomId(); const codeVerifier = createCodeVerifier(); const codeChallenge = createCodeChallenge(codeVerifier); const redirectTo = typeof req.query.redirectTo === 'string' && req.query.redirectTo.trim() ? req.query.redirectTo : frontendBaseUrl; const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`; await createOAuthState(stateId, providerKey, codeVerifier, redirectTo, expiresAt); const authorizationUrl = new URL(provider.authorizationEndpoint); authorizationUrl.searchParams.set('client_id', provider.clientId); authorizationUrl.searchParams.set('redirect_uri', redirectUri); authorizationUrl.searchParams.set('response_type', 'code'); authorizationUrl.searchParams.set('scope', provider.scopes); authorizationUrl.searchParams.set('state', stateId); if (providerKey === 'apple') { authorizationUrl.searchParams.set('response_mode', 'form_post'); } else { authorizationUrl.searchParams.set('code_challenge', codeChallenge); authorizationUrl.searchParams.set('code_challenge_method', 'S256'); } res.redirect(authorizationUrl.toString()); } catch (error) { next(error); } }); const handleOAuthCallback = async (req: Request, res: Response, next: NextFunction) => { const providerKey = req.params.provider as ProviderKey; const provider = oauthProviders[providerKey]; if (!provider) { res.status(404).send('Unknown authentication provider.'); return; } const code = typeof req.query.code === 'string' ? req.query.code : typeof req.body.code === 'string' ? req.body.code : ''; const state = typeof req.query.state === 'string' ? req.query.state : typeof req.body.state === 'string' ? req.body.state : ''; if (!code || !state) { res.status(400).send('Missing OAuth callback parameters.'); return; } try { const oauthState = await consumeOAuthState(state, providerKey); if (!oauthState) { res.status(400).send('OAuth session is invalid or expired.'); return; } const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`; const tokenBody = new URLSearchParams({ client_id: provider.clientId, client_secret: provider.clientSecret, code, grant_type: 'authorization_code', redirect_uri: redirectUri, }); if (providerKey !== 'apple') { tokenBody.set('code_verifier', oauthState.code_verifier); } const tokenResponse = await fetch(provider.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenBody, }); if (!tokenResponse.ok) { throw new Error(`Unable to complete ${provider.displayName} login.`); } const tokenJson = (await tokenResponse.json()) as { access_token?: string; id_token?: string }; const accessToken = tokenJson.access_token ?? ''; const idToken = tokenJson.id_token ?? ''; if (!accessToken && providerKey !== 'apple') { throw new Error(`Unable to complete ${provider.displayName} login.`); } let providerSubject = ''; let email = ''; let name = ''; if (providerKey === 'apple') { const claims = parseJwtPayload<{ sub?: string; email?: string }>(idToken); const bodyUser = typeof req.body.user === 'string' ? (JSON.parse(req.body.user) as { name?: { firstName?: string; lastName?: string } }) : null; providerSubject = String(claims.sub ?? ''); email = normalizeEmail(String(claims.email ?? '')); name = [bodyUser?.name?.firstName ?? '', bodyUser?.name?.lastName ?? ''].join(' ').trim() || email.split('@')[0] || 'User'; } else { const userInfoResponse = await fetch(provider.userinfoEndpoint, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!userInfoResponse.ok) { throw new Error(`Unable to read ${provider.displayName} profile.`); } const userInfo = (await userInfoResponse.json()) as Record; providerSubject = String(userInfo.sub ?? userInfo.id ?? ''); email = normalizeEmail(String(userInfo.email ?? userInfo.preferred_username ?? '')); name = String(userInfo.name ?? userInfo.given_name ?? email.split('@')[0] ?? 'User').trim(); } if (!providerSubject || !email) { throw new Error(`Unable to identify ${provider.displayName} account.`); } let user = await findUserByProviderAccount(providerKey, providerSubject); if (!user) { user = await findUserByEmail(email); } if (!user) { user = await createUser(email, name); } await linkAuthAccount(user!.id, providerKey, providerSubject, email); await claimWorkspaceInvites(user!); const activeWorkspaceId = await ensureDefaultWorkspaceForUser(user!); await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId); const { token } = await createAuthSession(user!.id, activeWorkspaceId); const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl); redirectUrl.searchParams.set('auth_token', token); res.redirect(redirectUrl.toString()); } catch (error) { next(error); } }; app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback); app.post('/api/auth/oauth/:provider/callback', handleOAuthCallback); app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { try { const summary = await getPlatformAdminSummary(); res.json({ summary: { totalBirds: Number(summary?.total_birds ?? 0), memorializedBirds: Number(summary?.memorialized_birds ?? 0), totalUsers: Number(summary?.total_users ?? 0), totalWorkspaces: Number(summary?.total_workspaces ?? 0), rescueWorkspaces: Number(summary?.rescue_workspaces ?? 0), rescueBirds: Number(summary?.rescue_birds ?? 0), pendingRescues: Number(summary?.pending_rescues ?? 0), dailyUsers: Number(summary?.daily_users ?? 0), }, }); } catch (error) { next(error); } }); app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { try { const rescueWorkspaces = await listRescueWorkspacesForAdmin(); res.json({ rescueWorkspaces: rescueWorkspaces.map(normalizeAdminRescueWorkspace) }); } catch (error) { next(error); } }); app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { try { const education = await listDailyEducationForAdmin(); res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) }); } catch (error) { next(error); } }); app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { try { const questions = await listEducationQuestionsForAdmin(); res.json({ questions: questions.map(normalizeEducationQuestion) }); } catch (error) { next(error); } }); app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { const parsed = dailyEducationSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() }); return; } try { const education = await upsertDailyEducation({ publishDate: parsed.data.publishDate, fact: parsed.data.fact, createdByUserId: req.auth!.user.id, }); res.json({ education: normalizeDailyEducation(education) }); } catch (error) { next(error); } }); app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { const parsed = educationQuestionSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() }); return; } try { const question = await createEducationQuestion({ question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) }, createdByUserId: req.auth!.user.id, }); res.status(201).json({ question: normalizeEducationQuestion(question) }); } catch (error) { next(error); } }); app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { const parsed = educationQuestionSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() }); return; } try { const question = await updateEducationQuestion(req.params.questionId, { ...parsed.data, explanation: emptyToNull(parsed.data.explanation), }); if (!question) { res.status(404).json({ error: 'Education question not found.' }); return; } res.json({ question: normalizeEducationQuestion(question) }); } catch (error) { next(error); } }); app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const deleted = await deleteEducationQuestion(req.params.questionId); if (!deleted) { res.status(404).json({ error: 'Education question not found.' }); return; } res.status(204).send(); } catch (error) { next(error); } }); app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const deleted = await deleteDailyEducation(req.params.educationId); if (!deleted) { res.status(404).json({ error: 'Daily education item not found.' }); return; } res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { const educationOptOut = await getEducationOptOut(req.auth!.user.id); const education = educationOptOut ? null : await getDailyEducationForDate(); const questions = education ? await listDailyEducationQuestions(education.publish_date) : []; res.json({ educationOptOut, education: education ? normalizeDailyEducation(education, questions) : null, }); } catch (error) { next(error); } }); app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { const parsed = educationPreferenceSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() }); return; } try { const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut); res.json({ educationOptOut }); } catch (error) { next(error); } }); app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid rescue verification payload', details: parsed.error.flatten() }); return; } try { const workspace = await updateRescueVerificationStatus( Number(req.params.workspaceId), parsed.data.rescueVerificationStatus as RescueVerificationStatus, ); if (!workspace) { res.status(404).json({ error: 'Rescue flock not found.' }); return; } await sendRescueStatusNotification({ workspace, ownerEmail: null, event: 'status_changed', }); res.json({ workspace: normalizeWorkspace(workspace) }); } catch (error) { next(error); } }); app.post( '/api/billing/checkout-session', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = z.object({ billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional() }).safeParse(req.body ?? {}); if (!parsed.success) { res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() }); return; } try { const workspace = req.auth!.workspace; if (workspace.workspace_type === 'rescue') { res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' }); return; } const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan; const billingInterval = parsed.data.billingInterval ?? workspace.billing_interval; const priceId = getStripePriceIdForBillingPlan(billingPlan, billingInterval); let stripeCustomerId = workspace.stripe_customer_id; if (!stripeCustomerId) { const customer = await getStripeClient().customers.create({ email: workspace.billing_email ?? req.auth!.user.email, name: workspace.name, metadata: { workspaceId: String(workspace.id), userId: req.auth!.user.id, }, }); stripeCustomerId = customer.id; await setWorkspaceStripeCustomerId(workspace.id, stripeCustomerId); } const checkoutSession = await getStripeClient().checkout.sessions.create({ mode: 'subscription', customer: stripeCustomerId, client_reference_id: String(workspace.id), line_items: [{ price: priceId, quantity: 1 }], success_url: stripeCheckoutSuccessUrl, cancel_url: stripeCheckoutCancelUrl, allow_promotion_codes: true, metadata: { workspaceId: String(workspace.id), billingPlan, billingInterval, }, subscription_data: { metadata: { workspaceId: String(workspace.id), billingPlan, billingInterval, }, }, }); if (!checkoutSession.url) { throw new Error('Stripe did not return a checkout URL.'); } res.json({ url: checkoutSession.url }); } catch (error) { next(error); } }, ); app.post( '/api/billing/portal-session', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const workspace = req.auth!.workspace; if (workspace.workspace_type === 'rescue') { res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' }); return; } if (!workspace.stripe_customer_id) { res.status(409).json({ error: 'Start a subscription before opening the billing portal.' }); return; } const portalSession = await getStripeClient().billingPortal.sessions.create({ customer: workspace.stripe_customer_id, return_url: stripePortalReturnUrl, }); res.json({ url: portalSession.url }); } catch (error) { next(error); } }, ); app.post( '/api/billing/sync', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const syncedWorkspace = await syncWorkspaceStripeBilling(req.auth!.workspace.id); if (!syncedWorkspace) { res.status(404).json({ error: 'Workspace not found.' }); return; } res.json({ workspace: normalizeWorkspace(syncedWorkspace) }); } catch (error) { next(error); } }, ); app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id); res.json({ integrationTokens: tokens.map(normalizeIntegrationToken) }); } catch (error) { next(error); } }); app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { const parsed = integrationTokenCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid integration token payload', details: parsed.error.flatten() }); return; } try { const rawToken = createIntegrationToken(); const expiresAt = parsed.data.expiresInDays ? new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000).toISOString() : null; const integrationToken = await createIntegrationTokenRecord({ userId: req.auth!.user.id, workspaceId: req.auth!.workspace.id, name: parsed.data.name, tokenHash: hashToken(rawToken), tokenPrefix: rawToken.slice(0, 16), scope: parsed.data.scope, expiresAt, }); await writeAuditLog(req.auth!, 'integration_token.created', 'integration_token', integrationToken!.id, integrationToken!.name, { scope: integrationToken!.scope, expiresAt: integrationToken!.expires_at, }); res.status(201).json({ integrationToken: normalizeIntegrationToken(integrationToken!), token: rawToken, }); } catch (error) { next(error); } }); app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { try { const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id); if (!revoked) { res.status(404).json({ error: 'Integration token not found.' }); return; } res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { res.json({ workspaces: await normalizeWorkspaceMembershipList(req.auth!.user.id), }); } catch (error) { next(error); } }); app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { const parsed = createWorkspaceSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() }); return; } try { const workspaceId = await getNextWorkspaceId(); const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan); if (parsed.data.workspaceType === 'rescue') { if (!parsed.data.rescueOnboarding) { res.status(400).json({ error: 'Rescue onboarding details are required.' }); return; } } const workspace = await createWorkspace({ id: workspaceId, name: parsed.data.name, workspaceType: parsed.data.workspaceType, billingEmail: emptyToNull(parsed.data.billingEmail), billingPlan, billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? 'monthly'), owner: req.auth!.user, }); if (workspace?.workspace_type === 'rescue') { const onboardingWebhookError = await trySendRescueOnboardingWebhook({ action: 'created', workspaceId: workspace.id, flockName: workspace.name, ownerEmail: req.auth!.user.email, requestedByUserId: req.auth!.user.id, rescueOnboarding: parsed.data.rescueOnboarding!, }); await sendRescueStatusNotification({ workspace, ownerEmail: req.auth!.user.email, event: 'created', note: onboardingWebhookError, }); } res.status(201).json({ workspace: normalizeWorkspace(workspace!) }); } catch (error) { next(error); } }); app.get('/api/workspace', requireAuth, async (req: Request, res: Response) => { res.json({ workspace: normalizeWorkspace(req.auth!.workspace) }); }); app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = workspaceSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() }); return; } try { const currentWorkspace = req.auth!.workspace; const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? currentWorkspace.billing_plan); const billingInterval = parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? currentWorkspace.billing_interval); const isConvertingToRescue = currentWorkspace.workspace_type !== 'rescue' && parsed.data.workspaceType === 'rescue'; const canUpdateWorkspace = isAdminUser(req.auth!.user) || subscriptionAllowsWrite(currentWorkspace) || isBillingOnlyWorkspaceUpdate(currentWorkspace, parsed.data); if (!canUpdateWorkspace) { res.status(402).json({ error: currentWorkspace.workspace_type === 'rescue' ? 'This rescue flock is read-only until FlockPal verifies it.' : 'This flock is read-only until the subscription is restored.', code: 'workspace_read_only', }); return; } if (isConvertingToRescue) { if (!parsed.data.rescueOnboarding) { res.status(400).json({ error: 'Rescue onboarding details are required.' }); return; } } const workspace = await updateWorkspace({ workspaceId: currentWorkspace.id, name: parsed.data.name, workspaceType: parsed.data.workspaceType, billingEmail: emptyToNull(parsed.data.billingEmail), billingPlan, billingInterval, }); if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) { const onboardingWebhookError = await trySendRescueOnboardingWebhook({ action: 'converted', workspaceId: workspace.id, flockName: workspace.name, ownerEmail: req.auth!.user.email, requestedByUserId: req.auth!.user.id, rescueOnboarding: parsed.data.rescueOnboarding!, }); await sendRescueStatusNotification({ workspace, ownerEmail: req.auth!.user.email, event: 'converted', note: onboardingWebhookError, }); } await writeAuditLog(req.auth!, 'workspace.updated', 'workspace', String(workspace!.id), workspace!.name, { workspaceType: workspace!.workspace_type, billingPlan: workspace!.billing_plan, }); res.json({ workspace: normalizeWorkspace(workspace!) }); } catch (error) { next(error); } }); app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => { try { if ((await getWorkspaceTotalBirdCount(req.auth!.workspace.id)) > 0) { res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' }); return; } const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace); let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id); if (!nextWorkspaceId) { const fallbackWorkspaceId = await getNextWorkspaceId(); const fallbackWorkspace = await createWorkspace({ id: fallbackWorkspaceId, name: 'New Flock', workspaceType: 'standard', billingEmail: req.auth!.user.email, billingPlan: 'household_basic', billingInterval: 'monthly', owner: req.auth!.user, }); nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId; } await updateSessionWorkspace(req.auth!.session.id, nextWorkspaceId); const deletion = await deleteWorkspaceIfEmpty(req.auth!.workspace.id); if (!deletion.deleted) { await updateSessionWorkspace(req.auth!.session.id, req.auth!.workspace.id); res.status(404).json({ error: 'Flock not found.' }); return; } const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token); if (!updatedAuth) { throw new Error('Unable to reload session.'); } res.json({ deletedWorkspaceId: req.auth!.workspace.id, canceledStripeSubscriptionId, token: req.auth!.token, session: await buildSessionPayload(updatedAuth), }); } catch (error) { next(error); } }); app.post( '/api/workspace/rescue-status/cancel', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const workspace = await cancelRescueVerificationRequest(req.auth!.workspace.id); if (!workspace) { res.status(409).json({ error: 'Only pending rescue status requests can be canceled.' }); return; } await sendRescueStatusNotification({ workspace, ownerEmail: req.auth!.user.email, event: 'canceled', }); res.json({ workspace: normalizeWorkspace(workspace) }); } catch (error) { next(error); } }, ); app.get('/api/workspace/members', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const members = await listWorkspaceMembers(req.auth!.workspace.id); res.json({ members: members.map(normalizeWorkspaceMember) }); } catch (error) { next(error); } }); app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = workspaceMemberSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid flock member payload', details: parsed.error.flatten() }); return; } try { const inviteEmail = normalizeEmail(parsed.data.email); const existingUser = await findUserByEmail(inviteEmail); const member = await upsertWorkspaceMember({ workspaceId: req.auth!.workspace.id, inviteEmail, name: parsed.data.name, role: parsed.data.role, existingUser, }); await writeAuditLog(req.auth!, 'workspace_member.upserted', 'workspace_member', member!.id, member!.name, { inviteEmail: member!.invite_email, role: member!.role, }); res.status(201).json({ member: normalizeWorkspaceMember(member!) }); } catch (error) { next(error); } }); app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = z.object({ role: workspaceRoleSchema }).safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() }); return; } try { const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : ''; const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email)); if (parsed.data.role === 'owner' && !requesterIsBillingOwner) { res.status(403).json({ error: 'Only the billing owner can promote collaborators to owner.' }); return; } const member = await updateWorkspaceMemberRole({ memberId: req.params.memberId, workspaceId: req.auth!.workspace.id, role: parsed.data.role, requesterMemberId: req.auth!.membership.id, requesterIsBillingOwner, requesterRole: req.auth!.membership.role, billingEmail, }); if (!member) { res.status(404).json({ error: 'Flock member not found or cannot be changed.' }); return; } await writeAuditLog(req.auth!, 'workspace_member.role_updated', 'workspace_member', member.id, member.name, { role: member.role, }); res.json({ member: normalizeWorkspaceMember(member) }); } catch (error) { next(error); } }); app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : ''; const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email)); const deleted = await deleteWorkspaceMember({ memberId: req.params.memberId, workspaceId: req.auth!.workspace.id, requesterMemberId: req.auth!.membership.id, requesterIsBillingOwner, }); if (!deleted) { res.status(404).json({ error: 'Flock member not found or cannot be removed.' }); return; } await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/notes', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const notes = await listFlockNotes(req.auth!.workspace.id); res.json({ notes: notes.map(normalizeFlockNote) }); } catch (error) { next(error); } }); app.post('/api/notes', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = flockNoteSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid note payload', details: parsed.error.flatten() }); return; } try { const note = await createFlockNote({ workspaceId: req.auth!.workspace.id, birdId: emptyToNull(parsed.data.birdId ?? ''), body: parsed.data.body, createdByUserId: req.auth!.user.id, }); if (!note) { res.status(404).json({ error: 'Flock member not found for this note.' }); return; } await writeAuditLog(req.auth!, 'note.created', 'note', note.id, note.bird_name ?? 'Note', { birdId: note.bird_id, birdName: note.bird_name, }); res.status(201).json({ note: normalizeFlockNote(note) }); } catch (error) { next(error); } }); app.delete('/api/notes/:noteId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { try { const deleted = await deleteFlockNote(req.params.noteId, req.auth!.workspace.id); if (!deleted) { res.status(404).json({ error: 'Note not found.' }); return; } await writeAuditLog(req.auth!, 'note.deleted', 'note', deleted.id, deleted.title); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 250); const entries = await listAuditLogEntries(req.auth!.workspace.id, limit); res.json({ entries: entries.map(normalizeAuditLogEntry) }); } catch (error) { next(error); } }); 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', '5'); 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([ listBirds(req.auth!.workspace.id), listMemorializedBirds(req.auth!.workspace.id), ]); res.json({ birds: birds.map(normalizeBird), memorializedBirds: memorializedBirds.map(normalizeBird) }); } catch (error) { next(error); } }); 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 : ''; const photoAccess = verifyBirdPhotoAccessToken(token); if (!photoAccess || photoAccess.birdId !== req.params.birdId) { res.status(403).json({ error: 'Photo link expired or invalid.' }); return; } const bird = await getBirdById(photoAccess.birdId, photoAccess.workspaceId); if (!bird || bird.photo_object_key !== photoAccess.objectKey) { res.status(404).json({ error: 'Photo not found.' }); return; } const s3Config = getS3ImageStorageConfig(); if (!s3Config) { res.status(503).json({ error: 'Image storage is not configured.' }); return; } const signedUrl = getSignedS3ObjectUrl({ config: s3Config, objectKey: bird.photo_object_key, expiresInSeconds: 5 * 60, }); res.setHeader('Cache-Control', 'private, max-age=900'); if (photoDeliveryMode === 'redirect') { res.redirect(302, signedUrl); return; } const imageResponse = await fetch(signedUrl); if (!imageResponse.ok) { res.status(imageResponse.status).json({ error: 'Unable to load bird photo.' }); return; } const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || 'application/octet-stream'; const contentLength = imageResponse.headers.get('content-length'); const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); res.setHeader('Content-Type', contentType); if (contentLength) { res.setHeader('Content-Length', contentLength); } res.send(imageBuffer); } catch (error) { next(error); } }); app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() }); return; } let uploadedObjectKeyToCleanup: string | null = null; try { 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, workspaceId: req.auth!.workspace.id, name: parsed.data.name, tagId: normalizeBandId(parsed.data.tagId), species: parsed.data.species, 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.hatchDay || parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: photoStorage.photoDataUrl, photoObjectKey: photoStorage.photoObjectKey, photoContentType: photoStorage.photoContentType, photoUpdatedAt: photoStorage.photoUpdatedAt, notifyOnDob: parsed.data.notifyOnDob ?? false, notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, publicProfileCode: createPublicProfileCode(), publicProfileEnabled: parsed.data.publicProfileEnabled ?? false, }); uploadedObjectKeyToCleanup = null; await writeAuditLog(req.auth!, 'bird.created', 'bird', bird!.id, bird!.name, { 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); if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' }); return; } next(error); } }); app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = flockTransferSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid flock transfer payload', details: parsed.error.flatten() }); return; } try { const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail); const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); if (!sourceBird) { res.status(404).json({ error: 'Bird not found.' }); return; } if (!ensureBirdWritable(sourceBird, res)) { return; } const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id); if (!targetWorkspaces.length) { await createPendingBirdTransfer({ birdId: sourceBird.id, sourceWorkspaceId: req.auth!.workspace.id, destinationOwnerEmail, requestedByUserId: req.auth!.user.id, }); const delivery = await issueBirdTransferInvite({ email: destinationOwnerEmail, birdName: sourceBird.name, sourceWorkspaceName: req.auth!.workspace.name, redirectTo: frontendBaseUrl, }); await writeAuditLog(req.auth!, 'bird.transfer_invited', 'bird', sourceBird.id, sourceBird.name, { destinationOwnerEmail, }); res.status(202).json({ ok: true, bird: normalizeBird(sourceBird), destinationOwnerEmail, inviteSent: true, invitePreviewUrl: delivery.previewUrl, inviteDelivery: delivery.delivered ? 'email' : 'preview', message: 'A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock.', }); return; } if (targetWorkspaces.length > 1) { res.status(409).json({ error: 'That owner email has more than one flock. Ask the receiving owner to use a unique owner email before transferring.' }); return; } const targetWorkspace = targetWorkspaces[0]; const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, targetWorkspace.id); if (!bird) { res.status(404).json({ error: 'Bird not found.' }); return; } await writeAuditLog(req.auth!, 'bird.transferred', 'bird', bird.id, bird.name, { 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') { res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' }); return; } next(error); } }); app.post( '/api/birds/:birdId/transfer-code', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); if (!sourceBird) { res.status(404).json({ error: 'Bird not found.' }); return; } if (!ensureBirdWritable(sourceBird, res)) { return; } let transferCode = null; for (let attempt = 0; attempt < 3; attempt += 1) { try { transferCode = await createBirdTransferCode({ code: createBirdTransferCodeValue(), birdId: sourceBird.id, sourceWorkspaceId: req.auth!.workspace.id, requestedByUserId: req.auth!.user.id, }); break; } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) { continue; } throw error; } } if (!transferCode) { throw new Error('Unable to create bird transfer code.'); } await writeAuditLog(req.auth!, 'bird.transfer_code_created', 'bird', sourceBird.id, sourceBird.name, { transferCodeId: transferCode.id, }); res.status(201).json({ transferCode: { code: transferCode.code, bird: normalizeBird(sourceBird), }, }); } catch (error) { next(error); } }, ); app.post( '/api/bird-transfer-codes/:code/accept', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdTransferCodeSchema.safeParse(req.params.code); if (!parsed.success) { res.status(404).json({ error: 'Bird transfer code not found.' }); return; } try { const transferCode = await getOpenBirdTransferCode(parsed.data); if (!transferCode) { res.status(404).json({ error: 'Bird transfer code not found or already used.' }); return; } if (transferCode.source_workspace_id === req.auth!.workspace.id) { res.status(409).json({ error: 'This bird is already in your active flock.' }); return; } const bird = await transferBirdToWorkspace(transferCode.id, transferCode.source_workspace_id, req.auth!.workspace.id); if (!bird) { res.status(404).json({ error: 'Bird is no longer available for transfer.' }); return; } await markBirdTransferCodeCompleted(transferCode.transfer_code_id, req.auth!.workspace.id); await writeAuditLog(req.auth!, 'bird.transfer_code_accepted', 'bird', bird.id, bird.name, { sourceWorkspaceId: transferCode.source_workspace_id, sourceWorkspaceName: transferCode.workspace_name, transferCodeId: transferCode.transfer_code_id, }); 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) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' }); return; } next(error); } }, ); app.post( '/api/birds/:birdId/reports/adoption', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); if (!sourceBird) { res.status(404).json({ error: 'Bird not found.' }); return; } if (!ensureBirdWritable(sourceBird, res)) { return; } let transferCode = null; for (let attempt = 0; attempt < 3; attempt += 1) { try { transferCode = await createBirdTransferCode({ code: createBirdTransferCodeValue(), birdId: sourceBird.id, sourceWorkspaceId: req.auth!.workspace.id, requestedByUserId: req.auth!.user.id, }); break; } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) { continue; } throw error; } } if (!transferCode) { throw new Error('Unable to create bird transfer code.'); } await adoptionReportQueueEvents.waitUntilReady(); const reportJob = await enqueueAdoptionReportJob({ birdId: sourceBird.id, workspaceId: req.auth!.workspace.id, transferCode: transferCode.code, printFriendly: req.query.printFriendly === 'true', }); const reportResult = await reportJob.waitUntilFinished(adoptionReportQueueEvents, adoptionReportRenderTimeoutMs); const pdf = Buffer.from(reportResult.pdfBase64, 'base64'); await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, { transferCodeId: transferCode.id, printFriendly: req.query.printFriendly === 'true', }); const safeName = sourceBird.name .trim() .replace(/[^a-z0-9]+/gi, '-') .replace(/^-+|-+$/g, '') .toLowerCase() || 'bird'; res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename="flockpal-adoption-report-${safeName}.pdf"`); res.setHeader('Content-Length', pdf.length.toString()); res.setHeader('X-FlockPal-Transfer-Code', transferCode.code); res.send(pdf); } catch (error) { next(error); } }, ); app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() }); return; } let uploadedObjectKeyToCleanup: string | null = null; try { const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); if (!existingBird) { res.status(404).json({ error: 'Bird not found.' }); return; } if (!ensureBirdWritable(existingBird, res)) { return; } const photoStorage = await resolveBirdPhotoStorage({ birdId: req.params.birdId, workspaceId: req.auth!.workspace.id, photoDataUrl: emptyToNull(parsed.data.photoDataUrl), existingBird, }); 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, name: parsed.data.name, tagId: normalizeBandId(parsed.data.tagId), species: parsed.data.species, 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.hatchDay || parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: photoStorage.photoDataUrl, photoObjectKey: photoStorage.photoObjectKey, photoContentType: photoStorage.photoContentType, photoUpdatedAt: photoStorage.photoUpdatedAt, notifyOnDob: parsed.data.notifyOnDob ?? false, notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, publicProfileCode: existingBird.public_profile_code ?? createPublicProfileCode(), publicProfileEnabled: parsed.data.publicProfileEnabled ?? false, }); if (!bird) { res.status(404).json({ error: 'Bird not found.' }); return; } uploadedObjectKeyToCleanup = null; await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete); await writeAuditLog(req.auth!, 'bird.updated', 'bird', bird.id, bird.name, { 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); if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' }); return; } next(error); } }); app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), 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; } if (!ensureBirdWritable(bird, res)) { return; } const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id); if (!deleted) { res.status(404).json({ error: 'Bird not found.' }); return; } await writeAuditLog(req.auth!, 'bird.deleted', 'bird', bird.id, bird.name); res.status(204).send(); await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key); } catch (error) { next(error); } }); app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => { const parsed = memorializeBirdSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid memorial payload', details: parsed.error.flatten() }); return; } try { const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); if (!existingBird) { res.status(404).json({ error: 'Bird not found.' }); return; } if (!ensureBirdWritable(existingBird, res)) { return; } const bird = await memorializeBird({ birdId: req.params.birdId, workspaceId: req.auth!.workspace.id, memorializedOn: parsed.data.memorializedOn, memorialNote: emptyToNull(parsed.data.memorialNote), notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? false, }); if (!bird) { res.status(404).json({ error: 'Bird not found.' }); return; } await writeAuditLog(req.auth!, 'bird.memorialized', 'bird', bird.id, bird.name, { memorializedOn: bird.memorialized_on, }); res.json({ bird: normalizeBird(bird) }); } catch (error) { next(error); } }); app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => { const parsed = memorialReminderPreferenceSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid memorial reminder payload', details: parsed.error.flatten() }); return; } try { const bird = await updateMemorialReminderPreference({ birdId: req.params.birdId, workspaceId: req.auth!.workspace.id, notifyOnMemorialDay: parsed.data.notifyOnMemorialDay, }); if (!bird) { res.status(404).json({ error: 'Memorialized bird not found.' }); return; } await writeAuditLog(req.auth!, 'bird.memorial_reminder_updated', 'bird', bird.id, bird.name, { notifyOnMemorialDay: bird.notify_on_memorial_day, }); res.json({ bird: normalizeBird(bird) }); } catch (error) { next(error); } }); app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425); const weights = await listWeightsForBird(req.params.birdId, req.auth!.workspace.id, days); res.json({ weights: weights.map(normalizeWeight) }); } catch (error) { next(error); } }); app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = weightSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid weight 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 weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); await writeAuditLog(req.auth!, 'weight.created', 'weight', weight!.id, bird.name, { birdId: bird.id, weightGrams: parsed.data.weightGrams, recordedOn: parsed.data.recordedOn, }); res.status(201).json({ weight: normalizeWeight(weight!) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' }); return; } next(error); } }); app.put( '/api/birds/:birdId/weights/:weightId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = weightSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid weight 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 weight = await updateWeightForBird( req.params.weightId, req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes), ); if (!weight) { res.status(404).json({ error: 'Weight entry not found or no longer editable.' }); return; } await writeAuditLog(req.auth!, 'weight.updated', 'weight', weight.id, bird.name, { birdId: bird.id, weightGrams: parsed.data.weightGrams, recordedOn: parsed.data.recordedOn, }); res.json({ weight: normalizeWeight(weight) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' }); return; } next(error); } }, ); app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id); res.json({ vetVisits: vetVisits.map(normalizeVetVisit) }); } catch (error) { next(error); } }); app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = vetVisitSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid vet visit 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 vetVisit = await createVetVisitForBird( req.params.birdId, parsed.data.visitedOn, parsed.data.clinicName, parsed.data.reason, emptyToNull(parsed.data.notes), ); await writeAuditLog(req.auth!, 'vet_visit.created', 'vet_visit', vetVisit!.id, bird.name, { birdId: bird.id, visitedOn: parsed.data.visitedOn, reason: parsed.data.reason, }); res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) }); } catch (error) { next(error); } }); app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = vetVisitSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid vet visit 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 vetVisit = await updateVetVisitForBird( req.params.visitId, req.params.birdId, parsed.data.visitedOn, parsed.data.clinicName, parsed.data.reason, emptyToNull(parsed.data.notes), ); if (!vetVisit) { res.status(404).json({ error: 'Vet visit not found.' }); return; } await writeAuditLog(req.auth!, 'vet_visit.updated', 'vet_visit', vetVisit.id, bird.name, { birdId: bird.id, visitedOn: parsed.data.visitedOn, reason: parsed.data.reason, }); res.json({ vetVisit: normalizeVetVisit(vetVisit) }); } catch (error) { next(error); } }); app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), 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; } if (!ensureBirdWritable(bird, res)) { return; } const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId); if (!deleted) { res.status(404).json({ error: 'Vet visit not found.' }); return; } await writeAuditLog(req.auth!, 'vet_visit.deleted', 'vet_visit', req.params.visitId, bird.name, { birdId: bird.id, }); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/birds/:birdId/medications', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const medications = await listMedicationsForBird(req.params.birdId, req.auth!.workspace.id); res.json({ medications: medications.map(normalizeMedication) }); } catch (error) { next(error); } }); app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = medicationSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid medication 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 medication = await createMedicationForBird( req.params.birdId, parsed.data.name, parsed.data.dosage, parsed.data.frequency, parsed.data.doseSchedule, emptyToNull(parsed.data.route), parsed.data.startDate, emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.notes), parsed.data.remindersEnabled ?? false, ); await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, { birdId: bird.id, birdName: bird.name, }); res.status(201).json({ medication: normalizeMedication(medication!) }); } catch (error) { next(error); } }); app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = medicationSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid medication 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 medication = await updateMedicationForBird( req.params.medicationId, req.params.birdId, parsed.data.name, parsed.data.dosage, parsed.data.frequency, parsed.data.doseSchedule, emptyToNull(parsed.data.route), parsed.data.startDate, emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.notes), parsed.data.remindersEnabled ?? false, ); if (!medication) { res.status(404).json({ error: 'Medication not found.' }); return; } await writeAuditLog(req.auth!, 'medication.updated', 'medication', medication.id, medication.name, { birdId: bird.id, birdName: bird.name, }); res.json({ medication: normalizeMedication(medication) }); } catch (error) { next(error); } }); app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), 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; } if (!ensureBirdWritable(bird, res)) { return; } const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId); if (!deleted) { res.status(404).json({ error: 'Medication not found.' }); return; } await writeAuditLog(req.auth!, 'medication.deleted', 'medication', req.params.medicationId, bird.name, { birdId: bird.id, }); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/birds/:birdId/medication-administrations', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const administrations = await listMedicationAdministrationsForBird(req.params.birdId, req.auth!.workspace.id); res.json({ administrations: administrations.map(normalizeMedicationAdministration) }); } catch (error) { next(error); } }); app.post('/api/birds/:birdId/medications/:medicationId/administrations', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = medicationAdministrationSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid medication administration 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 administration = await upsertMedicationAdministrationForBird( req.params.medicationId, req.params.birdId, req.auth!.workspace.id, parsed.data.administeredOn, parsed.data.administrationSlot, parsed.data.status, emptyToNull(parsed.data.notes), req.auth!.user.id, ); if (!administration) { res.status(404).json({ error: 'Medication not found.' }); return; } await writeAuditLog(req.auth!, 'medication_administration.recorded', 'medication_administration', administration.id, bird.name, { birdId: bird.id, medicationId: req.params.medicationId, administeredOn: parsed.data.administeredOn, status: parsed.data.status, }); res.status(201).json({ administration: normalizeMedicationAdministration(administration) }); } catch (error) { next(error); } }); app.post('/api/admin/reminders/bird-milestones/run', requireAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { const parsed = z .object({ runDate: dateStringSchema.optional(), }) .safeParse(req.body ?? {}); if (!parsed.success) { res.status(400).json({ error: 'Invalid reminder run payload', details: parsed.error.flatten() }); return; } try { const result = await runBirdMilestoneReminders(parsed.data.runDate ?? getDateInTimeZone()); res.json(result); } catch (error) { next(error); } }); app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error(error); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); }); export const startApiServer = async () => { await ensureSchema(); app.listen(port, () => { console.log(`FlockPal backend listening on port ${port}`); }); }; const currentModulePath = fileURLToPath(import.meta.url); if (process.argv[1] === currentModulePath) { startApiServer().catch((error) => { console.error('Failed to start backend', error); process.exit(1); }); }