Files
FlockPal/backend/src/app.ts
T
2026-04-15 23:39:10 -04:00

1697 lines
54 KiB
TypeScript

import crypto from 'crypto';
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 from 'nodemailer';
import { z } from 'zod';
import { ensureSchema } from './db/schema.js';
import {
consumeMagicLinkToken,
consumeOAuthState,
createAuthSession as createAuthSessionRecord,
createMagicLinkToken,
createOAuthState,
createUser,
deleteAuthSession,
deleteExpiredMagicLinkTokens,
findUserByEmail,
findUserByProviderAccount,
linkAuthAccount,
resolveAuth as resolveSessionAuth,
resolveIntegrationTokenAuth,
updateSessionWorkspace,
updateUserName,
} from './repositories/authRepository.js';
import {
completePendingBirdTransfersForOwner,
createBird,
createPendingBirdTransfer,
createVetVisitForBird,
createWeightForBird,
deleteBird,
deleteVetVisitForBird,
getBirdById,
listBirds,
listVetVisitsForBird,
listWeightsForBird,
transferBirdToWorkspace,
updateBird,
updateVetVisitForBird,
} from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
import {
cancelRescueVerificationRequest,
claimWorkspaceInvites,
createWorkspace,
deleteWorkspaceMember,
deleteWorkspaceIfEmpty,
ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser,
getWorkspaceBirdCount,
getPlatformAdminSummary,
getMembershipForUser,
getNextWorkspaceId,
getWorkspaceById,
listOwnedWorkspacesByOwnerEmail,
listRescueWorkspacesForAdmin,
listMembershipsForUser,
listWorkspaceMembers,
updateRescueVerificationStatus,
updateWorkspace,
upsertWorkspaceMember,
} from './repositories/workspaceRepository.js';
import type {
AuthContext,
BillingPlan,
BirdGender,
BirdRow,
IntegrationTokenRow,
ProviderKey,
RescueVerificationStatus,
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() ?? '';
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'];
const allowedOrigins = Array.from(
new Set(
[process.env.FRONTEND_URL, process.env.FRONTEND_URLS]
.filter(Boolean)
.flatMap((value) => (value ?? '').split(','))
.map((origin) => origin.trim())
.filter(Boolean)
.concat(defaultAllowedOrigins),
),
);
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const photoDataUrlSchema = z
.string()
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
.max(1_500_000);
const magicLinkRequestSchema = z.object({
name: z.string().trim().max(160).optional().or(z.literal('')),
email: z.string().trim().email().max(255),
redirectTo: z.string().trim().url().max(2000).optional().or(z.literal('')),
});
const switchWorkspaceSchema = z.object({
workspaceId: z.coerce.number().int().positive(),
});
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160),
workspaceType: workspaceTypeSchema,
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
billingPlan: billingPlanSchema.optional(),
});
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(),
});
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 birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80),
species: z.string().trim().min(1).max(120),
gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
chartColor: chartColorSchema.optional(),
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
notifyOnDob: z.boolean().optional(),
notifyOnGotchaDay: z.boolean().optional(),
});
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema,
notes: z.string().trim().max(280).optional().or(z.literal('')),
});
const vetVisitSchema = z.object({
visitedOn: dateStringSchema,
clinicName: z.string().trim().min(1).max(160),
reason: z.string().trim().min(1).max(160),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
const integrationTokenCreateSchema = z.object({
name: z.string().trim().min(1).max(160),
scope: integrationTokenScopeSchema.default('read_write'),
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
});
const emptyToNull = (value?: string) => {
const trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null;
};
const normalizeEmail = (value: string) => value.trim().toLowerCase();
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
const createRandomId = () => crypto.randomUUID();
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
const resolveBillingPlan = (
workspaceType: WorkspaceType,
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
) => {
if (workspaceType === 'rescue') {
return 'rescue_free' as const;
}
if (requestedPlan === 'household_macaw') {
return 'household_macaw';
}
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic';
};
const smtpHost = process.env.SMTP_HOST?.trim() ?? '';
const smtpPort = Number(process.env.SMTP_PORT ?? 587);
const smtpSecure = process.env.SMTP_SECURE === 'true' || smtpPort === 465;
const smtpUser = process.env.SMTP_USER?.trim() ?? '';
const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
const 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,
subscriptionStatus: row.subscription_status,
rescueVerificationStatus: row.rescue_verification_status,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const normalizeAdminRescueWorkspace = (
row: WorkspaceRow & {
owner_email: string | null;
bird_count: number;
member_count: number;
},
) => ({
workspace: normalizeWorkspace(row),
ownerEmail: row.owner_email,
birdCount: Number(row.bird_count ?? 0),
memberCount: Number(row.member_count ?? 0),
});
const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
id: row.id,
workspaceId: row.workspace_id,
userId: row.user_id,
inviteEmail: row.invite_email,
name: row.name,
role: row.role,
acceptedAt: row.accepted_at,
createdAt: row.created_at,
});
const normalizeBird = (row: BirdRow) => ({
id: row.id,
workspaceId: row.workspace_id,
name: row.name,
tagId: row.tag_id,
species: row.species,
gender: row.gender,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
chartColor: row.chart_color,
photoDataUrl: row.photo_data_url,
notifyOnDob: row.notify_on_dob,
notifyOnGotchaDay: row.notify_on_gotcha_day,
createdAt: row.created_at,
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
latestRecordedOn: row.latest_recorded_on,
});
const normalizeWeight = (row: WeightRow) => ({
id: row.id,
birdId: row.bird_id,
weightGrams: Number(row.weight_grams),
recordedOn: row.recorded_on,
notes: row.notes,
});
const normalizeVetVisit = (row: VetVisitRow) => ({
id: row.id,
birdId: row.bird_id,
visitedOn: row.visited_on,
clinicName: row.clinic_name,
reason: row.reason,
notes: row.notes,
});
const 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.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,
}),
);
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: false }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
const normalizeWorkspaceMembershipList = async (userId: string) =>
(await listMembershipsForUser(userId)).map((row) => ({
membership: normalizeWorkspaceMember(row),
workspace: normalizeWorkspace({
id: row.workspace_id,
name: row.workspace_name,
workspace_type: row.workspace_type,
billing_email: row.billing_email,
billing_plan: row.billing_plan,
subscription_status: row.subscription_status,
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 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 sendRescueStatusNotification = async ({
workspace,
ownerEmail,
event,
}: {
workspace: WorkspaceRow;
ownerEmail: string | null;
event: 'created' | 'converted' | 'status_changed' | 'canceled';
}) => {
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
const eventLabel =
event === 'created'
? 'created'
: event === 'converted'
? 'converted to rescue'
: event === 'canceled'
? 'canceled rescue request'
: 'status updated';
const subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`;
const escapedWorkspaceName = escapeHtml(workspace.name);
const escapedStatusLabel = escapeHtml(statusLabel);
const escapedOwnerEmail = escapeHtml(ownerEmail ?? 'unknown');
const escapedBillingEmail = escapeHtml(workspace.billing_email ?? 'not set');
const lines = [
`Rescue flock: ${workspace.name}`,
`Event: ${eventLabel}`,
`Verification status: ${statusLabel}`,
`Owner email: ${ownerEmail ?? 'unknown'}`,
`Billing email: ${workspace.billing_email ?? 'not set'}`,
`Flock ID: ${workspace.id}`,
];
if (!mailTransport) {
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
return { delivered: false };
}
await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: rescueStatusNotificationEmail,
subject,
text: lines.join('\n'),
html: `
<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>
`,
});
return { delivered: true };
};
const issueMagicLinkInvite = async ({
email,
name,
redirectTo = frontendBaseUrl,
}: {
email: string;
name: string | null;
redirectTo?: string;
}) => {
await deleteExpiredMagicLinkTokens();
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await createMagicLinkToken(email, name, tokenHash, redirectTo, expiresAt);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
return sendMagicLink({
email,
name,
magicLinkUrl: verifyUrl.toString(),
});
};
const issueBirdTransferInvite = async ({
email,
birdName,
sourceWorkspaceName,
redirectTo = frontendBaseUrl,
}: {
email: string;
birdName: string;
sourceWorkspaceName: string;
redirectTo?: string;
}) => {
await deleteExpiredMagicLinkTokens();
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await createMagicLinkToken(email, null, tokenHash, redirectTo, expiresAt);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
const magicLinkUrl = verifyUrl.toString();
const subject = `${sourceWorkspaceName} sent you a bird transfer in FlockPal`;
const text = [
'Hi there,',
'',
`${sourceWorkspaceName} wants to transfer ${birdName} to your FlockPal account.`,
'Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.',
magicLinkUrl,
'',
'This link expires in 15 minutes and can only be used once.',
].join('\n');
if (!mailTransport) {
console.log(`Bird transfer invite for ${email}: ${magicLinkUrl}`);
return {
delivered: false,
previewUrl: magicLinkUrl,
};
}
await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: email,
subject,
text,
html: `
<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 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();
};
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true });
});
app.get('/api/auth/providers', (_req: Request, res: Response) => {
res.json({
providers: Object.values(oauthProviders).map((provider) => ({
providerKey: provider.providerKey,
displayName: provider.displayName,
enabled: Boolean(provider.clientId && provider.clientSecret),
})),
});
});
app.post('/api/auth/register', (_req: Request, res: Response) => {
res.status(410).json({ error: 'Password-based registration is disabled. Use a magic link or an identity provider.' });
});
app.post('/api/auth/login', (_req: Request, res: Response) => {
res.status(410).json({ error: 'Password-based sign-in is disabled. Use a magic link or an identity provider.' });
});
app.post('/api/auth/magic-link/request', async (req: Request, res: Response, next: NextFunction) => {
const parsed = magicLinkRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid magic link payload', details: parsed.error.flatten() });
return;
}
const email = normalizeEmail(parsed.data.email);
const name = emptyToNull(parsed.data.name);
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
try {
const delivery = await issueMagicLinkInvite({
email,
name,
redirectTo,
});
res.status(202).json({
ok: true,
message: 'If that address can sign in, a magic link is on the way.',
previewUrl: delivery.previewUrl,
delivery: delivery.delivered ? 'email' : 'preview',
});
} catch (error) {
next(error);
}
});
app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => {
const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
if (!rawToken) {
res.status(400).send('Missing magic link token.');
return;
}
try {
const magicLink = await consumeMagicLinkToken(hashToken(rawToken));
if (!magicLink) {
res.status(400).send('That sign-in link is invalid or expired.');
return;
}
let user = await findUserByEmail(magicLink.email);
if (!user) {
user = await createUser(magicLink.email, magicLink.name || magicLink.email.split('@')[0] || 'FlockPal User');
} else if (magicLink.name && !user.name.trim()) {
user = await updateUserName(user.id, magicLink.name);
}
await claimWorkspaceInvites(user!);
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
const memberships = await normalizeWorkspaceMembershipList(user!.id);
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl);
redirectUrl.searchParams.set('auth_token', token);
res.redirect(redirectUrl.toString());
} catch (error) {
next(error);
}
});
app.post('/api/auth/logout', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
await deleteAuthSession(req.auth!.session.id);
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/auth/session', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
res.json({
token: req.auth?.token,
session: await buildSessionPayload(req.auth!),
});
} catch (error) {
next(error);
}
});
app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = switchWorkspaceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock selection payload', details: parsed.error.flatten() });
return;
}
try {
const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId);
if (!membership) {
res.status(403).json({ error: 'You do not have access to that flock.' });
return;
}
await updateSessionWorkspace(req.auth!.session.id, parsed.data.workspaceId);
const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token);
if (!updatedAuth) {
throw new Error('Unable to reload session.');
}
res.json({
token: req.auth!.token,
session: await buildSessionPayload(updatedAuth),
});
} catch (error) {
next(error);
}
});
app.get('/api/auth/oauth/:provider/start', async (req: Request, res: Response, next: NextFunction) => {
const providerKey = req.params.provider as ProviderKey;
const provider = oauthProviders[providerKey];
if (!provider) {
res.status(404).json({ error: 'Unknown authentication provider.' });
return;
}
if (!provider.clientId || !provider.clientSecret) {
res.status(400).json({ error: `${provider.displayName} login is not configured.` });
return;
}
try {
const stateId = createRandomId();
const codeVerifier = createCodeVerifier();
const codeChallenge = createCodeChallenge(codeVerifier);
const redirectTo = typeof req.query.redirectTo === 'string' && req.query.redirectTo.trim() ? req.query.redirectTo : frontendBaseUrl;
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`;
await createOAuthState(stateId, providerKey, codeVerifier, redirectTo, expiresAt);
const authorizationUrl = new URL(provider.authorizationEndpoint);
authorizationUrl.searchParams.set('client_id', provider.clientId);
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('scope', provider.scopes);
authorizationUrl.searchParams.set('state', stateId);
if (providerKey === 'apple') {
authorizationUrl.searchParams.set('response_mode', 'form_post');
} else {
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
}
res.redirect(authorizationUrl.toString());
} catch (error) {
next(error);
}
});
const handleOAuthCallback = async (req: Request, res: Response, next: NextFunction) => {
const providerKey = req.params.provider as ProviderKey;
const provider = oauthProviders[providerKey];
if (!provider) {
res.status(404).send('Unknown authentication provider.');
return;
}
const code = typeof req.query.code === 'string' ? req.query.code : typeof req.body.code === 'string' ? req.body.code : '';
const state = typeof req.query.state === 'string' ? req.query.state : typeof req.body.state === 'string' ? req.body.state : '';
if (!code || !state) {
res.status(400).send('Missing OAuth callback parameters.');
return;
}
try {
const oauthState = await consumeOAuthState(state, providerKey);
if (!oauthState) {
res.status(400).send('OAuth session is invalid or expired.');
return;
}
const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`;
const tokenBody = new URLSearchParams({
client_id: provider.clientId,
client_secret: provider.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
});
if (providerKey !== 'apple') {
tokenBody.set('code_verifier', oauthState.code_verifier);
}
const tokenResponse = await fetch(provider.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenBody,
});
if (!tokenResponse.ok) {
throw new Error(`Unable to complete ${provider.displayName} login.`);
}
const tokenJson = (await tokenResponse.json()) as { access_token?: string; id_token?: string };
const accessToken = tokenJson.access_token ?? '';
const idToken = tokenJson.id_token ?? '';
if (!accessToken && providerKey !== 'apple') {
throw new Error(`Unable to complete ${provider.displayName} login.`);
}
let providerSubject = '';
let email = '';
let name = '';
if (providerKey === 'apple') {
const claims = parseJwtPayload<{ sub?: string; email?: string }>(idToken);
const bodyUser = typeof req.body.user === 'string' ? (JSON.parse(req.body.user) as { name?: { firstName?: string; lastName?: string } }) : null;
providerSubject = String(claims.sub ?? '');
email = normalizeEmail(String(claims.email ?? ''));
name = [bodyUser?.name?.firstName ?? '', bodyUser?.name?.lastName ?? ''].join(' ').trim() || email.split('@')[0] || 'User';
} else {
const userInfoResponse = await fetch(provider.userinfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!userInfoResponse.ok) {
throw new Error(`Unable to read ${provider.displayName} profile.`);
}
const userInfo = (await userInfoResponse.json()) as Record<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 ensurePersonalWorkspaceForUser(user!);
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
redirectUrl.searchParams.set('auth_token', token);
res.redirect(redirectUrl.toString());
} catch (error) {
next(error);
}
};
app.get('/api/auth/oauth/:provider/callback', handleOAuthCallback);
app.post('/api/auth/oauth/:provider/callback', handleOAuthCallback);
app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const summary = await getPlatformAdminSummary();
res.json({
summary: {
totalBirds: Number(summary?.total_birds ?? 0),
totalUsers: Number(summary?.total_users ?? 0),
totalWorkspaces: Number(summary?.total_workspaces ?? 0),
rescueWorkspaces: Number(summary?.rescue_workspaces ?? 0),
pendingRescues: Number(summary?.pending_rescues ?? 0),
dailyUsers: Number(summary?.daily_users ?? 0),
},
});
} catch (error) {
next(error);
}
});
app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const rescueWorkspaces = await listRescueWorkspacesForAdmin();
res.json({ rescueWorkspaces: rescueWorkspaces.map(normalizeAdminRescueWorkspace) });
} catch (error) {
next(error);
}
});
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid rescue verification payload', details: parsed.error.flatten() });
return;
}
try {
const workspace = await updateRescueVerificationStatus(
Number(req.params.workspaceId),
parsed.data.rescueVerificationStatus as RescueVerificationStatus,
);
if (!workspace) {
res.status(404).json({ error: 'Rescue flock not found.' });
return;
}
await sendRescueStatusNotification({
workspace,
ownerEmail: null,
event: 'status_changed',
});
res.json({ workspace: normalizeWorkspace(workspace) });
} catch (error) {
next(error);
}
});
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
res.json({ integrationTokens: tokens.map(normalizeIntegrationToken) });
} catch (error) {
next(error);
}
});
app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
const parsed = integrationTokenCreateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid integration token payload', details: parsed.error.flatten() });
return;
}
try {
const rawToken = createIntegrationToken();
const expiresAt = parsed.data.expiresInDays
? new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000).toISOString()
: null;
const integrationToken = await createIntegrationTokenRecord({
userId: req.auth!.user.id,
workspaceId: req.auth!.workspace.id,
name: parsed.data.name,
tokenHash: hashToken(rawToken),
tokenPrefix: rawToken.slice(0, 16),
scope: parsed.data.scope,
expiresAt,
});
res.status(201).json({
integrationToken: normalizeIntegrationToken(integrationToken!),
token: rawToken,
});
} catch (error) {
next(error);
}
});
app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
try {
const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id);
if (!revoked) {
res.status(404).json({ error: 'Integration token not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
res.json({
workspaces: await normalizeWorkspaceMembershipList(req.auth!.user.id),
});
} catch (error) {
next(error);
}
});
app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = createWorkspaceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() });
return;
}
try {
const workspaceId = await getNextWorkspaceId();
const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan);
const workspace = await createWorkspace({
id: workspaceId,
name: parsed.data.name,
workspaceType: parsed.data.workspaceType,
billingEmail: emptyToNull(parsed.data.billingEmail),
billingPlan,
owner: req.auth!.user,
});
if (workspace?.workspace_type === 'rescue') {
await sendRescueStatusNotification({
workspace,
ownerEmail: req.auth!.user.email,
event: 'created',
});
}
res.status(201).json({ workspace: normalizeWorkspace(workspace!) });
} catch (error) {
next(error);
}
});
app.get('/api/workspace', requireAuth, async (req: Request, res: Response) => {
res.json({ workspace: normalizeWorkspace(req.auth!.workspace) });
});
app.put('/api/workspace', requireAuth, requireWriteAccess, 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 billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? req.auth!.workspace.billing_plan);
const workspace = await updateWorkspace({
workspaceId: req.auth!.workspace.id,
name: parsed.data.name,
workspaceType: parsed.data.workspaceType,
billingEmail: emptyToNull(parsed.data.billingEmail),
billingPlan,
});
if (workspace?.workspace_type === 'rescue' && req.auth!.workspace.workspace_type !== 'rescue') {
await sendRescueStatusNotification({
workspace,
ownerEmail: req.auth!.user.email,
event: 'converted',
});
}
res.json({ workspace: normalizeWorkspace(workspace!) });
} catch (error) {
next(error);
}
});
app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
try {
if ((await getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) {
res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' });
return;
}
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
if (!nextWorkspaceId) {
const fallbackWorkspaceId = await getNextWorkspaceId();
const fallbackWorkspace = await createWorkspace({
id: fallbackWorkspaceId,
name: `${req.auth!.user.name}'s Flock`,
workspaceType: 'standard',
billingEmail: req.auth!.user.email,
billingPlan: 'household_basic',
owner: req.auth!.user,
});
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
}
await updateSessionWorkspace(req.auth!.session.id, nextWorkspaceId);
const deletion = await deleteWorkspaceIfEmpty(req.auth!.workspace.id);
if (!deletion.deleted) {
await updateSessionWorkspace(req.auth!.session.id, req.auth!.workspace.id);
res.status(404).json({ error: 'Flock not found.' });
return;
}
const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token);
if (!updatedAuth) {
throw new Error('Unable to reload session.');
}
res.json({
deletedWorkspaceId: req.auth!.workspace.id,
token: req.auth!.token,
session: await buildSessionPayload(updatedAuth),
});
} catch (error) {
next(error);
}
});
app.post(
'/api/workspace/rescue-status/cancel',
requireAuth,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
try {
const workspace = await cancelRescueVerificationRequest(req.auth!.workspace.id);
if (!workspace) {
res.status(409).json({ error: 'Only pending rescue status requests can be canceled.' });
return;
}
await sendRescueStatusNotification({
workspace,
ownerEmail: req.auth!.user.email,
event: 'canceled',
});
res.json({ workspace: normalizeWorkspace(workspace) });
} catch (error) {
next(error);
}
},
);
app.get('/api/workspace/members', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const members = await listWorkspaceMembers(req.auth!.workspace.id);
res.json({ members: members.map(normalizeWorkspaceMember) });
} catch (error) {
next(error);
}
});
app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = workspaceMemberSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock member payload', details: parsed.error.flatten() });
return;
}
try {
const inviteEmail = normalizeEmail(parsed.data.email);
const existingUser = await findUserByEmail(inviteEmail);
const member = await upsertWorkspaceMember({
workspaceId: req.auth!.workspace.id,
inviteEmail,
name: parsed.data.name,
role: parsed.data.role,
existingUser,
});
res.status(201).json({ member: normalizeWorkspaceMember(member!) });
} catch (error) {
next(error);
}
});
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
if (!deleted) {
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const birds = await listBirds(req.auth!.workspace.id);
res.json({ birds: birds.map(normalizeBird) });
} catch (error) {
next(error);
}
});
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await createBird({
workspaceId: req.auth!.workspace.id,
name: parsed.data.name,
tagId: parsed.data.tagId,
species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35',
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
notifyOnDob: parsed.data.notifyOnDob ?? false,
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
});
res.status(201).json({ bird: normalizeBird(bird!) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
return;
}
next(error);
}
});
app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = flockTransferSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock transfer payload', details: parsed.error.flatten() });
return;
}
try {
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!sourceBird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id);
if (!targetWorkspaces.length) {
await createPendingBirdTransfer({
birdId: sourceBird.id,
sourceWorkspaceId: req.auth!.workspace.id,
destinationOwnerEmail,
requestedByUserId: req.auth!.user.id,
});
const delivery = await issueBirdTransferInvite({
email: destinationOwnerEmail,
birdName: sourceBird.name,
sourceWorkspaceName: req.auth!.workspace.name,
redirectTo: frontendBaseUrl,
});
res.status(202).json({
ok: true,
bird: normalizeBird(sourceBird),
destinationOwnerEmail,
inviteSent: true,
invitePreviewUrl: delivery.previewUrl,
inviteDelivery: delivery.delivered ? 'email' : 'preview',
message:
'A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock.',
});
return;
}
if (targetWorkspaces.length > 1) {
res.status(409).json({ error: 'That owner email has more than one flock. Ask the receiving owner to use a unique owner email before transferring.' });
return;
}
const targetWorkspace = targetWorkspaces[0];
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, targetWorkspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
return;
}
next(error);
}
});
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await updateBird({
birdId: req.params.birdId,
workspaceId: req.auth!.workspace.id,
name: parsed.data.name,
tagId: parsed.data.tagId,
species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
chartColor: parsed.data.chartColor ?? '#cb3a35',
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
notifyOnDob: parsed.data.notifyOnDob ?? false,
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
});
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.json({ bird: normalizeBird(bird) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
return;
}
next(error);
}
});
app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id);
if (!deleted) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 365);
const weights = await listWeightsForBird(req.params.birdId, req.auth!.workspace.id, days);
res.json({ weights: weights.map(normalizeWeight) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = weightSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
res.status(201).json({ weight: normalizeWeight(weight!) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
return;
}
next(error);
}
});
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
res.json({ vetVisits: vetVisits.map(normalizeVetVisit) });
} catch (error) {
next(error);
}
});
app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = vetVisitSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const vetVisit = await createVetVisitForBird(
req.params.birdId,
parsed.data.visitedOn,
parsed.data.clinicName,
parsed.data.reason,
emptyToNull(parsed.data.notes),
);
res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) });
} catch (error) {
next(error);
}
});
app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = vetVisitSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const vetVisit = await updateVetVisitForBird(
req.params.visitId,
req.params.birdId,
parsed.data.visitedOn,
parsed.data.clinicName,
parsed.data.reason,
emptyToNull(parsed.data.notes),
);
if (!vetVisit) {
res.status(404).json({ error: 'Vet visit not found.' });
return;
}
res.json({ vetVisit: normalizeVetVisit(vetVisit) });
} catch (error) {
next(error);
}
});
app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId);
if (!deleted) {
res.status(404).json({ error: 'Vet visit not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
});
const start = async () => {
await ensureSchema();
app.listen(port, () => {
console.log(`FlockPal backend listening on port ${port}`);
});
};
start().catch((error) => {
console.error('Failed to start backend', error);
process.exit(1);
});