1697 lines
54 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
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);
|
|
});
|