import crypto from 'crypto'; import { existsSync } from 'fs'; import path from 'path'; 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 { 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, createMedicationForBird, createPendingBirdTransfer, findBirdsByBandId, createVetVisitForBird, createWeightForBird, deleteBird, deleteMedicationForBird, deleteVetVisitForBird, getBirdById, listBirds, listDueBirdMilestoneReminders, listMedicationAdministrationsForBird, listMedicationsForBird, listVetVisitsForBird, listWeightsForBird, transferBirdToWorkspace, updateBird, updateMedicationForBird, upsertMedicationAdministrationForBird, updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { cancelRescueVerificationRequest, claimWorkspaceInvites, createWorkspace, deleteWorkspaceMember, deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, getWorkspaceBirdCount, getPlatformAdminSummary, getMembershipForUser, getNextWorkspaceId, getWorkspaceById, listOwnedWorkspacesByOwnerEmail, listRescueWorkspacesForAdmin, listMembershipsForUser, listWorkspaceNotificationEmails, listWorkspaceMembers, setWorkspaceStripeCustomerId, setWorkspaceStripeSubscription, setWorkspaceSubscriptionStatusByStripeSubscriptionId, updateRescueVerificationStatus, updateWorkspace, upsertWorkspaceMember, } from './repositories/workspaceRepository.js'; import type { AuthContext, BillingInterval, BillingPlan, BirdGender, BirdMilestoneReminderCandidateRow, BirdRow, IntegrationTokenRow, LostBirdMatchRow, MedicationRow, MedicationAdministrationRow, 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 milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; const milestoneReminderCheckIntervalMs = 60 * 60 * 1000; 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 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']); const billingIntervalSchema = z.enum(['monthly', 'yearly']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); 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(), }); 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(), }); 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 birdSchema = z.object({ name: z.string().trim().min(1).max(120), tagId: z.string().trim().min(1).max(80), species: z.string().trim().min(1).max(120), gender: birdGenderSchema.optional(), dateOfBirth: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')), chartColor: chartColorSchema.optional(), photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')), notifyOnDob: z.boolean().optional(), notifyOnGotchaDay: z.boolean().optional(), }); 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('')), }) .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 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 emptyToNull = (value?: string) => { const trimmed = value?.trim() ?? ''; return trimmed ? 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 createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`; 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', ) => { if (workspaceType === 'rescue') { return 'rescue_free' as const; } 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 stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`; const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`; const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl; 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_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '', yearly: process.env.STRIPE_PRICE_HOUSEHOLD_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_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_MACAW'], yearly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY'], }, }; const stripePricePlanLabels: Record, string> = { household_basic: 'Conure', household_plus: 'Indian Ringneck', household_macaw: '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 & { owner_email: string | null; bird_count: number; member_count: number; }, ) => ({ workspace: normalizeWorkspace(row), ownerEmail: row.owner_email, 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 normalizeBird = (row: BirdRow) => ({ id: row.id, workspaceId: row.workspace_id, name: row.name, tagId: row.tag_id, species: row.species, gender: row.gender, dateOfBirth: row.date_of_birth, gotchaDay: row.gotcha_day, chartColor: row.chart_color, photoDataUrl: row.photo_data_url, notifyOnDob: row.notify_on_dob, notifyOnGotchaDay: row.notify_on_gotcha_day, createdAt: row.created_at, latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null, latestRecordedOn: row.latest_recorded_on, }); 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, }); 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 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({ 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.' }, }); 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 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 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 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.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 getEmailTrackPatternAttachment = () => ({ filename: 'flockpal-x-pattern.svg', content: ``, contentType: 'image/svg+xml', cid: 'flockpal-x-pattern', contentDisposition: 'inline' as const, }); 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 getDefaultBirdPhotoAttachment = () => { const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda.png'); if (!existsSync(defaultPhotoPath)) { console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`); return null; } return { filename: 'yoda.png', path: defaultPhotoPath, cid: 'flockpal-default-bird-photo', contentDisposition: 'inline' as const, }; }; const sendRescueStatusNotification = async ({ workspace, ownerEmail, event, }: { workspace: WorkspaceRow; ownerEmail: string | null; event: 'created' | 'converted' | 'status_changed' | 'canceled'; }) => { 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}`, ]; 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}
`, }); return { delivered: true }; }; 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}`, `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)}
  • 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', }; } 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 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 trackPatternAttachment = getEmailTrackPatternAttachment(); 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); } attachments.push(trackPatternAttachment); 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 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, }; }; let lastMilestoneReminderRunDate = ''; 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 result = await runBirdMilestoneReminders(runDate); console.log( `Bird milestone reminders completed for ${result.runDate}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`, ); }; 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); }; 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 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.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/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 ensurePersonalWorkspaceForUser(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 ensurePersonalWorkspaceForUser(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), totalUsers: Number(summary?.total_users ?? 0), totalWorkspaces: Number(summary?.total_workspaces ?? 0), rescueWorkspaces: Number(summary?.rescue_workspaces ?? 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.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessionAuth, requireAdmin, 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.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, }); 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); 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') { await sendRescueStatusNotification({ workspace, ownerEmail: req.auth!.user.email, event: 'created', }); } 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 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; } 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' && currentWorkspace.workspace_type !== 'rescue') { await sendRescueStatusNotification({ workspace, ownerEmail: req.auth!.user.email, event: 'converted', }); } 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 getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) { res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' }); return; } 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: `${req.auth!.user.name}'s 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, 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, }); res.status(201).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 deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id); if (!deleted) { res.status(404).json({ error: 'Flock member not found or cannot be removed.' }); return; } res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const birds = await listBirds(req.auth!.workspace.id); res.json({ birds: birds.map(normalizeBird) }); } 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; } try { const bird = await createBird({ workspaceId: req.auth!.workspace.id, name: parsed.data.name, tagId: parsed.data.tagId, species: parsed.data.species, gender: (parsed.data.gender ?? 'unknown') as BirdGender, dateOfBirth: emptyToNull(parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: emptyToNull(parsed.data.photoDataUrl), notifyOnDob: parsed.data.notifyOnDob ?? false, notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, }); res.status(201).json({ bird: normalizeBird(bird!) }); } 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 this flock.' }); 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; } 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, }); 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; } res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' }); return; } next(error); } }); app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() }); return; } try { const bird = await updateBird({ birdId: req.params.birdId, workspaceId: req.auth!.workspace.id, name: parsed.data.name, tagId: parsed.data.tagId, species: parsed.data.species, gender: (parsed.data.gender ?? 'unknown') as BirdGender, dateOfBirth: emptyToNull(parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), chartColor: parsed.data.chartColor ?? '#cb3a35', photoDataUrl: emptyToNull(parsed.data.photoDataUrl), notifyOnDob: parsed.data.notifyOnDob ?? false, notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, }); if (!bird) { res.status(404).json({ error: 'Bird not found.' }); return; } res.json({ bird: normalizeBird(bird) }); } 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 this flock.' }); return; } next(error); } }); app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { try { const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id); if (!deleted) { res.status(404).json({ error: 'Bird not found.' }); return; } res.status(204).send(); } 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; } const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); 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.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; } const vetVisit = await createVetVisitForBird( req.params.birdId, parsed.data.visitedOn, parsed.data.clinicName, parsed.data.reason, emptyToNull(parsed.data.notes), ); 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; } 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; } 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; } const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId); if (!deleted) { res.status(404).json({ error: 'Vet visit not found.' }); return; } 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; } 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), ); 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; } 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), ); if (!medication) { res.status(404).json({ error: 'Medication not found.' }); return; } 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; } const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId); if (!deleted) { res.status(404).json({ error: 'Medication not found.' }); return; } 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 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; } 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' }); }); const start = async () => { await ensureSchema(); app.listen(port, () => { console.log(`FlockPal backend listening on port ${port}`); }); startBirdMilestoneReminderScheduler(); }; start().catch((error) => { console.error('Failed to start backend', error); process.exit(1); });