Files
FlockPal/backend/src/app.ts
T
2026-05-30 22:46:31 -04:00

3805 lines
126 KiB
TypeScript

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 { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.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,
getBirdByPublicProfileCode,
listBirds,
listDueBirdMilestoneReminders,
listMemorializedBirds,
listMedicationAdministrationsForBird,
listMedicationsForBird,
listVetVisitsForBird,
listWeightsForBird,
memorializeBird,
transferBirdToWorkspace,
updateBird,
updateMemorialReminderPreference,
updateMedicationForBird,
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 {
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,
getWorkspaceBirdCount,
getWorkspaceTotalBirdCount,
listOwnedWorkspacesByOwnerEmail,
listRescueWorkspacesForAdmin,
listMembershipsForUser,
listWorkspaceNotificationEmails,
listWorkspaceMembers,
setWorkspaceStripeCustomerId,
setWorkspaceStripeSubscription,
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
updateRescueVerificationStatus,
updateWorkspace,
upsertWorkspaceMember,
} from './repositories/workspaceRepository.js';
import type {
AuthContext,
AuditLogEntryRow,
BillingInterval,
BillingPlan,
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdRow,
FlockNoteRow,
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;
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']);
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 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 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('')),
gender: birdGenderSchema.optional(),
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 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 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 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<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
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<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = {
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<Exclude<BillingPlan, 'rescue_free'>, 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 = <T extends Record<string, unknown>>(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 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,
gender: row.gender,
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 normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id,
workspaceId: row.workspace_id,
name: row.name,
favoriteSnack: row.favorite_snack,
gender: row.gender,
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,
});
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 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 requestMetrics = {
startedAt: new Date().toISOString(),
totalRequests: 0,
totalErrors: 0,
inFlightRequests: 0,
totalDurationMs: 0,
byStatus: {} as Record<string, number>,
byRoute: {} as Record<string, number>,
};
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 getBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return null;
}
if (billingPlan === 'household_basic') {
return 4;
}
if (billingPlan === 'household_plus') {
return 10;
}
if (billingPlan === 'household_macaw') {
return 16;
}
return null;
};
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
return status;
}
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: `
<p>Hi ${name || 'there'},</p>
<p>Use this secure link to sign in to FlockPal:</p>
<p><a href="${magicLinkUrl}">Sign in to FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
});
return {
delivered: true,
previewUrl: null,
};
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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.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 = `<svg xmlns="http://www.w3.org/2000/svg" width="680" height="188" viewBox="0 0 680 188"><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef5e7"/><stop offset=".52" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient><symbol id="track" viewBox="0 0 160 160"><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(30 80 80)"/><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(-30 80 80)"/></symbol></defs><rect width="680" height="188" fill="url(#wash)"/><g opacity=".68"><use href="#track" x="20" y="16" width="88" height="88" fill="#5bb3b7" transform="rotate(-12 64 60)"/><use href="#track" x="126" y="74" width="78" height="78" fill="#7eb773" transform="rotate(18 165 113)"/><use href="#track" x="232" y="20" width="104" height="104" fill="#f3a24a" transform="rotate(-26 284 72)"/><use href="#track" x="378" y="72" width="86" height="86" fill="#898b93" transform="rotate(28 421 115)"/><use href="#track" x="492" y="18" width="98" height="98" fill="#b9c945" transform="rotate(-18 541 67)"/><use href="#track" x="592" y="84" width="66" height="66" fill="#5bb3b7" transform="rotate(34 625 117)"/></g><g opacity=".32"><use href="#track" x="66" y="112" width="46" height="46" fill="#f3a24a" transform="rotate(36 89 135)"/><use href="#track" x="190" y="122" width="42" height="42" fill="#5bb3b7" transform="rotate(-20 211 143)"/><use href="#track" x="344" y="18" width="44" height="44" fill="#7eb773" transform="rotate(18 366 40)"/><use href="#track" x="474" y="126" width="48" height="48" fill="#f3a24a" transform="rotate(-34 498 150)"/><use href="#track" x="626" y="18" width="42" height="42" fill="#898b93" transform="rotate(22 647 39)"/></g></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 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: `
<p>A rescue flock was ${eventLabel}.</p>
<ul>
<li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li>
<li><strong>Verification status:</strong> ${escapedStatusLabel}</li>
<li><strong>Owner email:</strong> ${escapedOwnerEmail}</li>
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
<li><strong>Flock ID:</strong> ${workspace.id}</li>
</ul>
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
`,
});
return { delivered: true };
};
type RescueOnboardingPayload = z.infer<typeof rescueOnboardingSchema>;
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<typeof sendRescueOnboardingWebhook>[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: `
<p>Hi there,</p>
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</p>
<p>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.</p>
<p><a href="${magicLinkUrl}">Accept bird transfer in FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
});
return {
delivered: true,
previewUrl: null,
};
};
const sendLostBirdReportNotification = async ({
bird,
recipients,
report,
}: {
bird: LostBirdMatchRow;
recipients: string[];
report: z.infer<typeof lostBirdReportSchema>;
}) => {
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: `
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li>
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
<li><strong>Finder email:</strong> ${escapeHtml(finderEmail)}</li>
<li><strong>Found location:</strong> ${escapeHtml(foundLocation)}</li>
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
</ul>
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
`,
});
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 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<SendMailOptions['attachments']> = [];
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
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
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: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
});
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,
};
};
let lastMilestoneReminderRunDate = '';
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);
};
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<string, unknown>,
) => {
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 isBillingOnlyWorkspaceUpdate = (
workspace: WorkspaceRow,
payload: z.infer<typeof workspaceSchema>,
) => 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();
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,
},
});
} 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<string, unknown>;
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.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.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;
}
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
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/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/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 birdLimit = getBillingPlanBirdLimit(req.auth!.workspace.billing_plan);
if (birdLimit !== null) {
const currentBirdCount = await getWorkspaceBirdCount(req.auth!.workspace.id);
if (currentBirdCount >= birdLimit) {
res.status(409).json({
error: 'This flock has reached the bird limit for the selected plan. Upgrade the flock subscription or memorialize a bird before adding another.',
code: 'billing_plan_bird_limit_reached',
birdLimit,
currentBirdCount,
billingPlan: req.auth!.workspace.billing_plan,
});
return;
}
}
const birdId = crypto.randomUUID();
const photoStorage = await resolveBirdPhotoStorage({
birdId,
workspaceId: req.auth!.workspace.id,
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
});
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),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(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,
});
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 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;
}
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,
});
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;
}
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 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),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(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,
});
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 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 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.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),
);
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),
);
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);
});
}