2715 lines
92 KiB
TypeScript
2715 lines
92 KiB
TypeScript
import crypto from 'crypto';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import cors from 'cors';
|
|
import dotenv from 'dotenv';
|
|
import express, { type NextFunction, type Request, type Response } from 'express';
|
|
import rateLimit from 'express-rate-limit';
|
|
import helmet from 'helmet';
|
|
import morgan from 'morgan';
|
|
import nodemailer, { type SendMailOptions } from 'nodemailer';
|
|
import Stripe from 'stripe';
|
|
import { z } from 'zod';
|
|
|
|
import { ensureSchema } from './db/schema.js';
|
|
import {
|
|
consumeMagicLinkToken,
|
|
consumeOAuthState,
|
|
createAuthSession as createAuthSessionRecord,
|
|
createMagicLinkToken,
|
|
createOAuthState,
|
|
createUser,
|
|
deleteAuthSession,
|
|
deleteExpiredMagicLinkTokens,
|
|
findUserByEmail,
|
|
findUserByProviderAccount,
|
|
linkAuthAccount,
|
|
resolveAuth as resolveSessionAuth,
|
|
resolveIntegrationTokenAuth,
|
|
updateSessionWorkspace,
|
|
updateUserName,
|
|
} from './repositories/authRepository.js';
|
|
import {
|
|
completePendingBirdTransfersForOwner,
|
|
createBird,
|
|
createBirdMilestoneReminderDelivery,
|
|
createMedicationForBird,
|
|
createPendingBirdTransfer,
|
|
findBirdsByBandId,
|
|
createVetVisitForBird,
|
|
createWeightForBird,
|
|
deleteBird,
|
|
deleteMedicationForBird,
|
|
deleteVetVisitForBird,
|
|
getBirdById,
|
|
listBirds,
|
|
listDueBirdMilestoneReminders,
|
|
listMedicationAdministrationsForBird,
|
|
listMedicationsForBird,
|
|
listVetVisitsForBird,
|
|
listWeightsForBird,
|
|
transferBirdToWorkspace,
|
|
updateBird,
|
|
updateMedicationForBird,
|
|
upsertMedicationAdministrationForBird,
|
|
updateVetVisitForBird,
|
|
} from './repositories/birdRepository.js';
|
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
|
import {
|
|
cancelRescueVerificationRequest,
|
|
claimWorkspaceInvites,
|
|
createWorkspace,
|
|
deleteWorkspaceMember,
|
|
deleteWorkspaceIfEmpty,
|
|
ensurePersonalWorkspaceForUser,
|
|
findAlternateWorkspaceForUser,
|
|
getWorkspaceBirdCount,
|
|
getPlatformAdminSummary,
|
|
getMembershipForUser,
|
|
getNextWorkspaceId,
|
|
getWorkspaceById,
|
|
listOwnedWorkspacesByOwnerEmail,
|
|
listRescueWorkspacesForAdmin,
|
|
listMembershipsForUser,
|
|
listWorkspaceNotificationEmails,
|
|
listWorkspaceMembers,
|
|
setWorkspaceStripeCustomerId,
|
|
setWorkspaceStripeSubscription,
|
|
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
|
updateRescueVerificationStatus,
|
|
updateWorkspace,
|
|
upsertWorkspaceMember,
|
|
} from './repositories/workspaceRepository.js';
|
|
import type {
|
|
AuthContext,
|
|
BillingInterval,
|
|
BillingPlan,
|
|
BirdGender,
|
|
BirdMilestoneReminderCandidateRow,
|
|
BirdRow,
|
|
IntegrationTokenRow,
|
|
LostBirdMatchRow,
|
|
MedicationRow,
|
|
MedicationAdministrationRow,
|
|
ProviderKey,
|
|
RescueVerificationStatus,
|
|
SubscriptionStatus,
|
|
UserRow,
|
|
VetVisitRow,
|
|
WeightRow,
|
|
WorkspaceMemberRow,
|
|
WorkspaceRole,
|
|
WorkspaceRow,
|
|
WorkspaceType,
|
|
} from './types.js';
|
|
|
|
dotenv.config();
|
|
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
auth?: AuthContext;
|
|
}
|
|
}
|
|
}
|
|
|
|
const app = express();
|
|
const port = Number(process.env.PORT ?? 5000);
|
|
const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
|
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
|
const sessionDays = 30;
|
|
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
|
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
|
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
|
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
|
|
|
if (trustProxy) {
|
|
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
|
}
|
|
|
|
const defaultAllowedOrigins = [
|
|
'http://localhost:3000',
|
|
'http://127.0.0.1:3000',
|
|
'http://localhost:5173',
|
|
'http://127.0.0.1:5173',
|
|
'http://localhost:8088',
|
|
'http://127.0.0.1:8088',
|
|
'https://flockpal.app',
|
|
'https://www.flockpal.app',
|
|
];
|
|
|
|
const allowedOrigins = Array.from(
|
|
new Set(
|
|
[process.env.FRONTEND_URL, process.env.FRONTEND_URLS]
|
|
.filter(Boolean)
|
|
.flatMap((value) => (value ?? '').split(','))
|
|
.map((origin) => origin.trim())
|
|
.filter(Boolean)
|
|
.concat(defaultAllowedOrigins),
|
|
),
|
|
);
|
|
|
|
const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
|
const photoDataUrlSchema = z
|
|
.string()
|
|
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
|
|
.max(1_500_000);
|
|
|
|
const magicLinkRequestSchema = z.object({
|
|
name: z.string().trim().max(160).optional().or(z.literal('')),
|
|
email: z.string().trim().email().max(255),
|
|
redirectTo: z.string().trim().url().max(2000).optional().or(z.literal('')),
|
|
});
|
|
|
|
const switchWorkspaceSchema = z.object({
|
|
workspaceId: z.coerce.number().int().positive(),
|
|
});
|
|
|
|
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
|
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
|
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
|
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
|
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
|
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
|
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
|
|
|
const workspaceSchema = z.object({
|
|
name: z.string().trim().min(1).max(160),
|
|
workspaceType: workspaceTypeSchema,
|
|
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
|
billingPlan: billingPlanSchema.optional(),
|
|
billingInterval: billingIntervalSchema.optional(),
|
|
});
|
|
|
|
const createWorkspaceSchema = z.object({
|
|
name: z.string().trim().min(1).max(160),
|
|
workspaceType: workspaceTypeSchema,
|
|
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
|
billingPlan: billingPlanSchema.optional(),
|
|
billingInterval: billingIntervalSchema.optional(),
|
|
});
|
|
|
|
const workspaceMemberSchema = z.object({
|
|
name: z.string().trim().min(1).max(160),
|
|
email: z.string().trim().email().max(255),
|
|
role: workspaceRoleSchema,
|
|
});
|
|
|
|
const flockTransferSchema = z.object({
|
|
destinationOwnerEmail: z.string().trim().email().max(255),
|
|
});
|
|
|
|
const lostBirdReportSchema = z.object({
|
|
tagId: z.string().trim().min(1).max(80),
|
|
finderName: z.string().trim().max(160).optional().or(z.literal('')),
|
|
finderEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
|
foundLocation: z.string().trim().max(255).optional().or(z.literal('')),
|
|
message: z.string().trim().max(1000).optional().or(z.literal('')),
|
|
});
|
|
|
|
const birdSchema = z.object({
|
|
name: z.string().trim().min(1).max(120),
|
|
tagId: z.string().trim().min(1).max(80),
|
|
species: z.string().trim().min(1).max(120),
|
|
gender: birdGenderSchema.optional(),
|
|
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
|
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
|
chartColor: chartColorSchema.optional(),
|
|
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
|
|
notifyOnDob: z.boolean().optional(),
|
|
notifyOnGotchaDay: z.boolean().optional(),
|
|
});
|
|
|
|
const weightSchema = z.object({
|
|
weightGrams: z.coerce.number().positive().max(10000),
|
|
recordedOn: dateStringSchema,
|
|
notes: z.string().trim().max(280).optional().or(z.literal('')),
|
|
});
|
|
|
|
const vetVisitSchema = z.object({
|
|
visitedOn: dateStringSchema,
|
|
clinicName: z.string().trim().min(1).max(160),
|
|
reason: z.string().trim().min(1).max(160),
|
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
|
});
|
|
|
|
const medicationSchema = z
|
|
.object({
|
|
name: z.string().trim().min(1).max(160),
|
|
dosage: z.string().trim().min(1).max(160),
|
|
frequency: z.enum(['once_daily', 'twice_daily', 'every_8_hours', 'every_6_hours', 'as_needed']),
|
|
doseSchedule: z
|
|
.array(
|
|
z.object({
|
|
key: z.string().trim().min(1).max(80),
|
|
label: z.string().trim().min(1).max(80),
|
|
time: z.string().trim().regex(/^$|^\d{2}:\d{2}$/),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(8),
|
|
route: z.string().trim().max(80).optional().or(z.literal('')),
|
|
startDate: dateStringSchema,
|
|
endDate: dateStringSchema.optional().or(z.literal('')),
|
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
|
})
|
|
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
|
message: 'End date must be on or after start date.',
|
|
path: ['endDate'],
|
|
});
|
|
|
|
const medicationAdministrationSchema = z.object({
|
|
administeredOn: dateStringSchema,
|
|
administrationSlot: z.string().trim().min(1).max(80).default('dose-1'),
|
|
status: z.enum(['administered', 'missed']),
|
|
notes: z.string().trim().max(500).optional().or(z.literal('')),
|
|
});
|
|
|
|
const integrationTokenCreateSchema = z.object({
|
|
name: z.string().trim().min(1).max(160),
|
|
scope: integrationTokenScopeSchema.default('read_write'),
|
|
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
|
|
});
|
|
|
|
const emptyToNull = (value?: string) => {
|
|
const trimmed = value?.trim() ?? '';
|
|
return trimmed ? trimmed : null;
|
|
};
|
|
|
|
const normalizeEmail = (value: string) => value.trim().toLowerCase();
|
|
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
|
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
|
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
|
|
const createRandomId = () => crypto.randomUUID();
|
|
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
|
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
|
|
const resolveBillingPlan = (
|
|
workspaceType: WorkspaceType,
|
|
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
|
|
) => {
|
|
if (workspaceType === 'rescue') {
|
|
return 'rescue_free' as const;
|
|
}
|
|
|
|
if (requestedPlan === 'household_macaw') {
|
|
return 'household_macaw';
|
|
}
|
|
|
|
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic';
|
|
};
|
|
|
|
const smtpHost = process.env.SMTP_HOST?.trim() ?? '';
|
|
const smtpPort = Number(process.env.SMTP_PORT ?? 587);
|
|
const smtpSecure = process.env.SMTP_SECURE === 'true' || smtpPort === 465;
|
|
const smtpUser = process.env.SMTP_USER?.trim() ?? '';
|
|
const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
|
|
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
|
|
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
|
|
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
|
|
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
|
|
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
|
|
const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`;
|
|
const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`;
|
|
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
|
|
const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
|
|
household_basic: {
|
|
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '',
|
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY?.trim() ?? '',
|
|
},
|
|
household_plus: {
|
|
monthly:
|
|
process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY?.trim() ||
|
|
process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ||
|
|
'',
|
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
|
|
},
|
|
household_macaw: {
|
|
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '',
|
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY?.trim() ?? '',
|
|
},
|
|
};
|
|
const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = {
|
|
household_basic: {
|
|
monthly: ['STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_CONURE'],
|
|
yearly: ['STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY'],
|
|
},
|
|
household_plus: {
|
|
monthly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK'],
|
|
yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'],
|
|
},
|
|
household_macaw: {
|
|
monthly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_MACAW'],
|
|
yearly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY'],
|
|
},
|
|
};
|
|
const stripePricePlanLabels: Record<Exclude<BillingPlan, 'rescue_free'>, string> = {
|
|
household_basic: 'Conure',
|
|
household_plus: 'Indian Ringneck',
|
|
household_macaw: 'Macaw',
|
|
};
|
|
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
|
const adminEmails = new Set(
|
|
(process.env.ADMIN_EMAILS ?? '')
|
|
.split(',')
|
|
.map((email) => normalizeEmail(email))
|
|
.filter(Boolean),
|
|
);
|
|
|
|
const mailTransport =
|
|
smtpHost && smtpFromEmail
|
|
? nodemailer.createTransport({
|
|
host: smtpHost,
|
|
port: smtpPort,
|
|
secure: smtpSecure,
|
|
auth: smtpUser && smtpPass ? { user: smtpUser, pass: smtpPass } : undefined,
|
|
})
|
|
: null;
|
|
|
|
const parseJwtPayload = <T extends Record<string, unknown>>(token: string) => {
|
|
const segments = token.split('.');
|
|
if (segments.length < 2) {
|
|
throw new Error('Invalid token payload.');
|
|
}
|
|
|
|
return JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8')) as T;
|
|
};
|
|
|
|
const normalizeUser = (row: UserRow) => ({
|
|
id: row.id,
|
|
email: row.email,
|
|
name: row.name,
|
|
createdAt: row.created_at,
|
|
});
|
|
|
|
const normalizeWorkspace = (row: WorkspaceRow) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
workspaceType: row.workspace_type,
|
|
billingEmail: row.billing_email,
|
|
billingPlan: row.billing_plan,
|
|
billingInterval: row.billing_interval,
|
|
subscriptionStatus: row.subscription_status,
|
|
stripeCustomerId: row.stripe_customer_id,
|
|
stripeSubscriptionId: row.stripe_subscription_id,
|
|
rescueVerificationStatus: row.rescue_verification_status,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
});
|
|
|
|
const normalizeAdminRescueWorkspace = (
|
|
row: WorkspaceRow & {
|
|
owner_email: string | null;
|
|
bird_count: number;
|
|
member_count: number;
|
|
},
|
|
) => ({
|
|
workspace: normalizeWorkspace(row),
|
|
ownerEmail: row.owner_email,
|
|
birdCount: Number(row.bird_count ?? 0),
|
|
memberCount: Number(row.member_count ?? 0),
|
|
});
|
|
|
|
const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
|
id: row.id,
|
|
workspaceId: row.workspace_id,
|
|
userId: row.user_id,
|
|
inviteEmail: row.invite_email,
|
|
name: row.name,
|
|
role: row.role,
|
|
acceptedAt: row.accepted_at,
|
|
createdAt: row.created_at,
|
|
});
|
|
|
|
const normalizeBird = (row: BirdRow) => ({
|
|
id: row.id,
|
|
workspaceId: row.workspace_id,
|
|
name: row.name,
|
|
tagId: row.tag_id,
|
|
species: row.species,
|
|
gender: row.gender,
|
|
dateOfBirth: row.date_of_birth,
|
|
gotchaDay: row.gotcha_day,
|
|
chartColor: row.chart_color,
|
|
photoDataUrl: row.photo_data_url,
|
|
notifyOnDob: row.notify_on_dob,
|
|
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
|
createdAt: row.created_at,
|
|
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
|
|
latestRecordedOn: row.latest_recorded_on,
|
|
});
|
|
|
|
const normalizeWeight = (row: WeightRow) => ({
|
|
id: row.id,
|
|
birdId: row.bird_id,
|
|
weightGrams: Number(row.weight_grams),
|
|
recordedOn: row.recorded_on,
|
|
notes: row.notes,
|
|
});
|
|
|
|
const normalizeVetVisit = (row: VetVisitRow) => ({
|
|
id: row.id,
|
|
birdId: row.bird_id,
|
|
visitedOn: row.visited_on,
|
|
clinicName: row.clinic_name,
|
|
reason: row.reason,
|
|
notes: row.notes,
|
|
});
|
|
|
|
const normalizeMedication = (row: MedicationRow) => ({
|
|
id: row.id,
|
|
birdId: row.bird_id,
|
|
name: row.name,
|
|
dosage: row.dosage,
|
|
frequency: row.frequency,
|
|
doseSchedule: row.dose_schedule,
|
|
route: row.route,
|
|
startDate: row.start_date,
|
|
endDate: row.end_date,
|
|
notes: row.notes,
|
|
});
|
|
|
|
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
|
id: row.id,
|
|
medicationId: row.medication_id,
|
|
birdId: row.bird_id,
|
|
administeredOn: row.administered_on,
|
|
administrationSlot: row.administration_slot,
|
|
status: row.status,
|
|
notes: row.notes,
|
|
createdByUserId: row.created_by_user_id,
|
|
createdAt: row.created_at,
|
|
});
|
|
|
|
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
workspaceId: row.workspace_id,
|
|
name: row.name,
|
|
tokenPrefix: row.token_prefix,
|
|
scope: row.scope,
|
|
lastUsedAt: row.last_used_at,
|
|
expiresAt: row.expires_at,
|
|
revokedAt: row.revoked_at,
|
|
createdAt: row.created_at,
|
|
});
|
|
|
|
const oauthProviders = {
|
|
google: {
|
|
providerKey: 'google' as const,
|
|
displayName: 'Google',
|
|
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
|
|
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
|
userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
|
|
scopes: 'openid email profile',
|
|
},
|
|
microsoft: {
|
|
providerKey: 'microsoft' as const,
|
|
displayName: 'Microsoft',
|
|
clientId: process.env.MICROSOFT_CLIENT_ID ?? '',
|
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET ?? '',
|
|
authorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo',
|
|
scopes: 'openid email profile User.Read',
|
|
},
|
|
apple: {
|
|
providerKey: 'apple' as const,
|
|
displayName: 'Apple',
|
|
clientId: process.env.APPLE_CLIENT_ID ?? '',
|
|
clientSecret: process.env.APPLE_CLIENT_SECRET ?? '',
|
|
authorizationEndpoint: 'https://appleid.apple.com/auth/authorize',
|
|
tokenEndpoint: 'https://appleid.apple.com/auth/token',
|
|
userinfoEndpoint: '',
|
|
scopes: 'name email',
|
|
},
|
|
};
|
|
|
|
app.disable('x-powered-by');
|
|
app.use(helmet({ crossOriginResourcePolicy: false }));
|
|
app.use(
|
|
cors({
|
|
origin(origin, callback) {
|
|
if (!origin || allowedOrigins.includes(origin)) {
|
|
callback(null, true);
|
|
return;
|
|
}
|
|
|
|
callback(new Error('Origin not allowed'));
|
|
},
|
|
}),
|
|
);
|
|
app.use(
|
|
rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
limit: 300,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
}),
|
|
);
|
|
const lostBirdReportLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
limit: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many found bird reports. Please try again later.' },
|
|
});
|
|
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
|
if (!stripeWebhookSecret) {
|
|
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
|
return;
|
|
}
|
|
|
|
const signature = req.headers['stripe-signature'];
|
|
|
|
if (!signature) {
|
|
res.status(400).json({ error: 'Missing Stripe signature.' });
|
|
return;
|
|
}
|
|
|
|
let event: Stripe.Event;
|
|
|
|
try {
|
|
event = getStripeClient().webhooks.constructEvent(req.body, signature, stripeWebhookSecret);
|
|
} catch (error) {
|
|
res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid Stripe webhook signature.' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (event.type === 'checkout.session.completed') {
|
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
const workspaceId = Number(session.metadata?.workspaceId ?? session.client_reference_id ?? 0);
|
|
const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
|
|
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
|
|
|
if (workspaceId && subscriptionId) {
|
|
const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId);
|
|
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
|
await setWorkspaceStripeSubscription({
|
|
workspaceId,
|
|
stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id),
|
|
stripeSubscriptionId: subscription.id,
|
|
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
|
|
billingPlan: billingSelection.billingPlan,
|
|
billingInterval: billingSelection.billingInterval,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (
|
|
event.type === 'customer.subscription.created' ||
|
|
event.type === 'customer.subscription.updated' ||
|
|
event.type === 'customer.subscription.deleted'
|
|
) {
|
|
const subscription = event.data.object as Stripe.Subscription;
|
|
const workspaceId = Number(subscription.metadata?.workspaceId ?? 0);
|
|
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
|
const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status);
|
|
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
|
|
|
if (workspaceId) {
|
|
await setWorkspaceStripeSubscription({
|
|
workspaceId,
|
|
stripeCustomerId: customerId,
|
|
stripeSubscriptionId: subscription.id,
|
|
subscriptionStatus,
|
|
billingPlan: billingSelection.billingPlan,
|
|
billingInterval: billingSelection.billingInterval,
|
|
});
|
|
} else {
|
|
await setWorkspaceSubscriptionStatusByStripeSubscriptionId(
|
|
subscription.id,
|
|
subscriptionStatus,
|
|
billingSelection.billingPlan,
|
|
billingSelection.billingInterval,
|
|
);
|
|
}
|
|
}
|
|
|
|
res.json({ received: true });
|
|
} catch (error) {
|
|
console.error('Stripe webhook handling failed', error);
|
|
res.status(500).json({ error: 'Unable to process Stripe webhook.' });
|
|
}
|
|
});
|
|
app.use(express.json({ limit: '2mb' }));
|
|
app.use(express.urlencoded({ extended: false }));
|
|
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
|
|
|
const normalizeWorkspaceMembershipList = async (userId: string) =>
|
|
(await listMembershipsForUser(userId)).map((row) => ({
|
|
membership: normalizeWorkspaceMember(row),
|
|
workspace: normalizeWorkspace({
|
|
id: row.workspace_id,
|
|
name: row.workspace_name,
|
|
workspace_type: row.workspace_type,
|
|
billing_email: row.billing_email,
|
|
billing_plan: row.billing_plan,
|
|
billing_interval: row.billing_interval,
|
|
subscription_status: row.subscription_status,
|
|
stripe_customer_id: row.stripe_customer_id,
|
|
stripe_subscription_id: row.stripe_subscription_id,
|
|
rescue_verification_status: row.rescue_verification_status,
|
|
created_at: row.workspace_created_at,
|
|
updated_at: row.workspace_updated_at,
|
|
}),
|
|
}));
|
|
|
|
const isAdminUser = (user: UserRow) => adminEmails.has(normalizeEmail(user.email));
|
|
|
|
const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
|
|
if (workspace.workspace_type === 'rescue') {
|
|
return workspace.rescue_verification_status === 'approved';
|
|
}
|
|
|
|
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
|
|
};
|
|
|
|
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
|
|
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
|
|
return status;
|
|
}
|
|
|
|
return 'none';
|
|
};
|
|
|
|
const getStripeClient = () => {
|
|
if (!stripe) {
|
|
throw new Error('Stripe is not configured.');
|
|
}
|
|
|
|
return stripe;
|
|
};
|
|
|
|
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
|
|
if (billingPlan === 'rescue_free') {
|
|
throw new Error('Rescue flocks do not use Stripe billing.');
|
|
}
|
|
|
|
const priceId = stripePriceByBillingPlanAndInterval[billingPlan]?.[billingInterval]?.trim() ?? '';
|
|
|
|
if (!priceId) {
|
|
const planLabel = stripePricePlanLabels[billingPlan] ?? billingPlan;
|
|
const envNames = stripePriceEnvNamesByBillingPlanAndInterval[billingPlan]?.[billingInterval] ?? [];
|
|
const envHint = envNames.length > 0 ? ` Set ${envNames.join(' or ')} in the backend environment.` : '';
|
|
|
|
throw new Error(`Stripe price is not configured for ${planLabel} ${billingInterval}.${envHint}`);
|
|
}
|
|
|
|
return priceId;
|
|
};
|
|
|
|
const getBillingSelectionForStripePrice = (priceId: string) => {
|
|
for (const [billingPlan, intervals] of Object.entries(stripePriceByBillingPlanAndInterval)) {
|
|
for (const [billingInterval, configuredPriceId] of Object.entries(intervals ?? {})) {
|
|
if (configuredPriceId && configuredPriceId === priceId) {
|
|
return {
|
|
billingPlan: billingPlan as BillingPlan,
|
|
billingInterval: billingInterval as BillingInterval,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
billingPlan: null,
|
|
billingInterval: null,
|
|
};
|
|
};
|
|
|
|
const getBillingSelectionForStripeSubscription = (subscription: Stripe.Subscription) => {
|
|
const priceId = subscription.items.data[0]?.price.id ?? '';
|
|
return getBillingSelectionForStripePrice(priceId);
|
|
};
|
|
|
|
const createAuthSession = async (userId: string, activeWorkspaceId: number) => {
|
|
const token = createSessionToken();
|
|
const tokenHash = hashToken(token);
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + sessionDays);
|
|
|
|
await createAuthSessionRecord(userId, activeWorkspaceId, tokenHash, expiresAt.toISOString());
|
|
|
|
return { token };
|
|
};
|
|
|
|
const buildSessionPayload = async (auth: AuthContext) => ({
|
|
user: normalizeUser(auth.user),
|
|
activeWorkspace: normalizeWorkspace(auth.workspace),
|
|
activeMembership: normalizeWorkspaceMember(auth.membership),
|
|
workspaces: await normalizeWorkspaceMembershipList(auth.user.id),
|
|
isAdmin: isAdminUser(auth.user),
|
|
providers: Object.values(oauthProviders).map((provider) => ({
|
|
providerKey: provider.providerKey,
|
|
displayName: provider.displayName,
|
|
enabled: Boolean(provider.clientId && provider.clientSecret),
|
|
})),
|
|
});
|
|
|
|
const sendMagicLink = async ({
|
|
email,
|
|
name,
|
|
magicLinkUrl,
|
|
}: {
|
|
email: string;
|
|
name: string | null;
|
|
magicLinkUrl: string;
|
|
}) => {
|
|
if (!mailTransport) {
|
|
console.log(`Magic sign-in link for ${email}: ${magicLinkUrl}`);
|
|
return {
|
|
delivered: false,
|
|
previewUrl: magicLinkUrl,
|
|
};
|
|
}
|
|
|
|
await mailTransport.sendMail({
|
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
|
to: email,
|
|
subject: 'Your FlockPal sign-in link',
|
|
text: [
|
|
`Hi ${name || 'there'},`,
|
|
'',
|
|
'Use this secure link to sign in to FlockPal:',
|
|
magicLinkUrl,
|
|
'',
|
|
'This link expires in 15 minutes and can only be used once.',
|
|
].join('\n'),
|
|
html: `
|
|
<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 getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).formatToParts(date);
|
|
const year = parts.find((part) => part.type === 'year')?.value ?? `${date.getUTCFullYear()}`;
|
|
const month = parts.find((part) => part.type === 'month')?.value ?? `${date.getUTCMonth() + 1}`.padStart(2, '0');
|
|
const day = parts.find((part) => part.type === 'day')?.value ?? `${date.getUTCDate()}`.padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
const formatOrdinal = (value: number) => {
|
|
const remainder = value % 100;
|
|
if (remainder >= 11 && remainder <= 13) {
|
|
return `${value}th`;
|
|
}
|
|
|
|
switch (value % 10) {
|
|
case 1:
|
|
return `${value}st`;
|
|
case 2:
|
|
return `${value}nd`;
|
|
case 3:
|
|
return `${value}rd`;
|
|
default:
|
|
return `${value}th`;
|
|
}
|
|
};
|
|
|
|
const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
|
|
const sourceDate = reminder.reminder_type === 'hatch_day' ? reminder.date_of_birth : reminder.gotcha_day;
|
|
const sourceYear = Number(sourceDate?.slice(0, 4));
|
|
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
|
|
};
|
|
|
|
const getFlockPalLogoAttachment = () => {
|
|
const logoPath = path.join(process.cwd(), 'assets', 'flockpal-logo.png');
|
|
|
|
if (!existsSync(logoPath)) {
|
|
console.warn(`Unable to load FlockPal email logo from ${logoPath}`);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
filename: 'flockpal-logo.png',
|
|
path: logoPath,
|
|
cid: 'flockpal-logo',
|
|
contentDisposition: 'inline' as const,
|
|
};
|
|
};
|
|
|
|
const getEmailTrackPatternAttachment = () => ({
|
|
filename: 'flockpal-x-pattern.svg',
|
|
content: `<svg xmlns="http://www.w3.org/2000/svg" width="680" height="188" viewBox="0 0 680 188"><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef5e7"/><stop offset=".52" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient><symbol id="track" viewBox="0 0 160 160"><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(30 80 80)"/><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(-30 80 80)"/></symbol></defs><rect width="680" height="188" fill="url(#wash)"/><g opacity=".68"><use href="#track" x="20" y="16" width="88" height="88" fill="#5bb3b7" transform="rotate(-12 64 60)"/><use href="#track" x="126" y="74" width="78" height="78" fill="#7eb773" transform="rotate(18 165 113)"/><use href="#track" x="232" y="20" width="104" height="104" fill="#f3a24a" transform="rotate(-26 284 72)"/><use href="#track" x="378" y="72" width="86" height="86" fill="#898b93" transform="rotate(28 421 115)"/><use href="#track" x="492" y="18" width="98" height="98" fill="#b9c945" transform="rotate(-18 541 67)"/><use href="#track" x="592" y="84" width="66" height="66" fill="#5bb3b7" transform="rotate(34 625 117)"/></g><g opacity=".32"><use href="#track" x="66" y="112" width="46" height="46" fill="#f3a24a" transform="rotate(36 89 135)"/><use href="#track" x="190" y="122" width="42" height="42" fill="#5bb3b7" transform="rotate(-20 211 143)"/><use href="#track" x="344" y="18" width="44" height="44" fill="#7eb773" transform="rotate(18 366 40)"/><use href="#track" x="474" y="126" width="48" height="48" fill="#f3a24a" transform="rotate(-34 498 150)"/><use href="#track" x="626" y="18" width="42" height="42" fill="#898b93" transform="rotate(22 647 39)"/></g></svg>`,
|
|
contentType: 'image/svg+xml',
|
|
cid: 'flockpal-x-pattern',
|
|
contentDisposition: 'inline' as const,
|
|
});
|
|
|
|
const parseDataImage = (dataUrl: string) => {
|
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
contentType: match[1],
|
|
content: Buffer.from(match[2], 'base64'),
|
|
};
|
|
};
|
|
|
|
const getDefaultBirdPhotoAttachment = () => {
|
|
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda.png');
|
|
|
|
if (!existsSync(defaultPhotoPath)) {
|
|
console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
filename: 'yoda.png',
|
|
path: defaultPhotoPath,
|
|
cid: 'flockpal-default-bird-photo',
|
|
contentDisposition: 'inline' as const,
|
|
};
|
|
};
|
|
|
|
const sendRescueStatusNotification = async ({
|
|
workspace,
|
|
ownerEmail,
|
|
event,
|
|
}: {
|
|
workspace: WorkspaceRow;
|
|
ownerEmail: string | null;
|
|
event: 'created' | 'converted' | 'status_changed' | 'canceled';
|
|
}) => {
|
|
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
|
|
const eventLabel =
|
|
event === 'created'
|
|
? 'created'
|
|
: event === 'converted'
|
|
? 'converted to rescue'
|
|
: event === 'canceled'
|
|
? 'canceled rescue request'
|
|
: 'status updated';
|
|
const subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`;
|
|
const escapedWorkspaceName = escapeHtml(workspace.name);
|
|
const escapedStatusLabel = escapeHtml(statusLabel);
|
|
const escapedOwnerEmail = escapeHtml(ownerEmail ?? 'unknown');
|
|
const escapedBillingEmail = escapeHtml(workspace.billing_email ?? 'not set');
|
|
const lines = [
|
|
`Rescue flock: ${workspace.name}`,
|
|
`Event: ${eventLabel}`,
|
|
`Verification status: ${statusLabel}`,
|
|
`Owner email: ${ownerEmail ?? 'unknown'}`,
|
|
`Billing email: ${workspace.billing_email ?? 'not set'}`,
|
|
`Flock ID: ${workspace.id}`,
|
|
];
|
|
|
|
if (!mailTransport) {
|
|
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
|
|
return { delivered: false };
|
|
}
|
|
|
|
await mailTransport.sendMail({
|
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
|
to: rescueStatusNotificationEmail,
|
|
subject,
|
|
text: lines.join('\n'),
|
|
html: `
|
|
<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 sendLostBirdReportNotification = async ({
|
|
bird,
|
|
recipients,
|
|
report,
|
|
}: {
|
|
bird: LostBirdMatchRow;
|
|
recipients: string[];
|
|
report: z.infer<typeof lostBirdReportSchema>;
|
|
}) => {
|
|
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
|
|
|
if (!uniqueRecipients.length) {
|
|
return { delivered: false };
|
|
}
|
|
|
|
const finderName = emptyToNull(report.finderName) ?? 'Not provided';
|
|
const finderEmail = emptyToNull(report.finderEmail) ?? 'Not provided';
|
|
const foundLocation = emptyToNull(report.foundLocation) ?? 'Not provided';
|
|
const message = emptyToNull(report.message) ?? 'Not provided';
|
|
const subject = `Possible found bird report for ${bird.name}`;
|
|
const lines = [
|
|
`A possible found bird report was submitted for ${bird.name}.`,
|
|
'',
|
|
`Band ID: ${bird.tag_id}`,
|
|
`Species: ${bird.species}`,
|
|
`Flock: ${bird.workspace_name}`,
|
|
'',
|
|
`Finder name: ${finderName}`,
|
|
`Finder email: ${finderEmail}`,
|
|
`Found location: ${foundLocation}`,
|
|
`Message: ${message}`,
|
|
'',
|
|
'FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.',
|
|
];
|
|
|
|
if (!mailTransport) {
|
|
console.log(`Found bird report for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
|
return { delivered: false };
|
|
}
|
|
|
|
await mailTransport.sendMail({
|
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
|
to: smtpFromEmail,
|
|
bcc: uniqueRecipients,
|
|
replyTo: emptyToNull(report.finderEmail) ?? undefined,
|
|
subject,
|
|
text: lines.join('\n'),
|
|
html: `
|
|
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
|
|
<ul>
|
|
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id)}</li>
|
|
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
|
|
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
|
|
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
|
|
<li><strong>Finder email:</strong> ${escapeHtml(finderEmail)}</li>
|
|
<li><strong>Found location:</strong> ${escapeHtml(foundLocation)}</li>
|
|
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
|
|
</ul>
|
|
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
|
|
`,
|
|
});
|
|
|
|
return { delivered: true };
|
|
};
|
|
|
|
const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidateRow) => {
|
|
const yearCount = getMilestoneYearCount(reminder);
|
|
const anniversaryLabel = yearCount > 0 ? formatOrdinal(yearCount) : '';
|
|
|
|
if (reminder.reminder_type === 'hatch_day') {
|
|
return {
|
|
subject: `Happy Hatch Day, ${reminder.name}!`,
|
|
eyebrow: 'Hatch Day',
|
|
headline: `Happy Hatch Day, ${reminder.name}!`,
|
|
eventName: 'Hatch Day',
|
|
intro:
|
|
yearCount > 0
|
|
? `From our flock to yours, wishing ${reminder.name} a happy Hatch Day! ${reminder.name} is ${yearCount} year${yearCount === 1 ? '' : 's'} old today.`
|
|
: `${reminder.name} has a Hatch Day today.`,
|
|
body: 'Cue the favorite snacks, extra head scritches if approved, and one tiny spotlight for the bird of the day.',
|
|
milestoneLabel: yearCount > 0 ? `${yearCount} year${yearCount === 1 ? '' : 's'} old` : 'Hatch Day on file',
|
|
};
|
|
}
|
|
|
|
return {
|
|
subject: `It's ${reminder.name}'s Gotcha Day!`,
|
|
eyebrow: 'Gotcha Day',
|
|
headline: `It's ${reminder.name}'s Gotcha Day!`,
|
|
eventName: 'Gotcha Day',
|
|
intro:
|
|
yearCount > 0
|
|
? `From our flock to yours, wishing ${reminder.name} a happy Gotcha Day! ${reminder.name} joined the flock ${yearCount} year${yearCount === 1 ? '' : 's'} ago today.`
|
|
: `${reminder.name} has a Gotcha Day today.`,
|
|
body: 'A good day to remember the first ride home, the first brave step up, and every happy moment spent together.',
|
|
milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Gotcha Day` : 'Gotcha Day on file',
|
|
};
|
|
};
|
|
|
|
const sendBirdMilestoneReminderNotification = async ({
|
|
reminder,
|
|
recipients,
|
|
}: {
|
|
reminder: BirdMilestoneReminderCandidateRow;
|
|
recipients: string[];
|
|
}) => {
|
|
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
|
|
|
if (!uniqueRecipients.length) {
|
|
return { delivered: false };
|
|
}
|
|
|
|
const copy = buildBirdMilestoneReminderCopy(reminder);
|
|
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
|
const logoAttachment = getFlockPalLogoAttachment();
|
|
const trackPatternAttachment = getEmailTrackPatternAttachment();
|
|
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
|
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
|
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
|
|
|
if (logoAttachment) {
|
|
attachments.push(logoAttachment);
|
|
}
|
|
attachments.push(trackPatternAttachment);
|
|
|
|
if (uploadedBirdPhoto) {
|
|
attachments.push({
|
|
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
|
|
content: uploadedBirdPhoto.content,
|
|
contentType: uploadedBirdPhoto.contentType,
|
|
cid: birdPhotoCid,
|
|
contentDisposition: 'inline',
|
|
});
|
|
} else if (defaultBirdPhoto) {
|
|
attachments.push(defaultBirdPhoto);
|
|
}
|
|
|
|
const birdPhotoHtml = birdPhotoCid
|
|
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
|
|
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
|
|
const lines = [
|
|
copy.headline,
|
|
'',
|
|
copy.intro,
|
|
copy.body,
|
|
'',
|
|
`Bird: ${reminder.name}`,
|
|
`Species: ${reminder.species}`,
|
|
`${copy.eventName}: ${copy.milestoneLabel}`,
|
|
'',
|
|
`Open FlockPal: ${frontendBaseUrl}`,
|
|
];
|
|
|
|
if (!mailTransport) {
|
|
console.log(`Bird milestone reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
|
return { delivered: false };
|
|
}
|
|
|
|
await mailTransport.sendMail({
|
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
|
to: smtpFromEmail,
|
|
bcc: uniqueRecipients,
|
|
subject: copy.subject,
|
|
text: lines.join('\n'),
|
|
attachments,
|
|
html: `
|
|
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('cid:flockpal-x-pattern'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
|
|
<div style="max-width: 680px; margin: 0 auto 18px;">
|
|
<img src="cid:flockpal-x-pattern" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
|
</div>
|
|
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
|
|
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
|
|
${
|
|
logoAttachment
|
|
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
|
|
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
|
|
}
|
|
</div>
|
|
<div style="padding: 30px 28px;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
|
<tr>
|
|
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
|
|
${birdPhotoHtml}
|
|
</td>
|
|
<td style="vertical-align: top; padding: 0 0 20px;">
|
|
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
|
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<p style="margin: 4px 0 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
|
<p style="margin: 0;">
|
|
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div style="max-width: 680px; margin: 18px auto 0;">
|
|
<img src="cid:flockpal-x-pattern" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
|
</div>
|
|
</div>
|
|
`,
|
|
});
|
|
|
|
return { delivered: true };
|
|
};
|
|
|
|
const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
|
const reminders = await listDueBirdMilestoneReminders(runDate);
|
|
let sent = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
|
|
for (const reminder of reminders) {
|
|
try {
|
|
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
|
|
const result = await sendBirdMilestoneReminderNotification({ reminder, recipients });
|
|
|
|
if (!result.delivered) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
const delivery = await createBirdMilestoneReminderDelivery({
|
|
birdId: reminder.id,
|
|
workspaceId: reminder.workspace_id,
|
|
reminderType: reminder.reminder_type,
|
|
reminderYear: reminder.reminder_year,
|
|
deliveredOn: runDate,
|
|
});
|
|
|
|
if (delivery) {
|
|
sent += 1;
|
|
} else {
|
|
skipped += 1;
|
|
}
|
|
} catch (error) {
|
|
failed += 1;
|
|
console.error(`Unable to send ${reminder.reminder_type} reminder for bird ${reminder.id}`, error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
runDate,
|
|
checked: reminders.length,
|
|
sent,
|
|
skipped,
|
|
failed,
|
|
};
|
|
};
|
|
|
|
let lastMilestoneReminderRunDate = '';
|
|
|
|
const startBirdMilestoneReminderScheduler = () => {
|
|
if (!milestoneRemindersEnabled) {
|
|
console.log('Bird milestone reminders are disabled.');
|
|
return;
|
|
}
|
|
|
|
const runIfNeeded = async () => {
|
|
const runDate = getDateInTimeZone();
|
|
if (lastMilestoneReminderRunDate === runDate) {
|
|
return;
|
|
}
|
|
|
|
lastMilestoneReminderRunDate = runDate;
|
|
const result = await runBirdMilestoneReminders(runDate);
|
|
console.log(
|
|
`Bird milestone reminders completed for ${result.runDate}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
|
);
|
|
};
|
|
|
|
setTimeout(() => {
|
|
void runIfNeeded().catch((error) => {
|
|
lastMilestoneReminderRunDate = '';
|
|
console.error('Bird milestone reminder scheduler failed', error);
|
|
});
|
|
}, 15_000);
|
|
|
|
setInterval(() => {
|
|
void runIfNeeded().catch((error) => {
|
|
lastMilestoneReminderRunDate = '';
|
|
console.error('Bird milestone reminder scheduler failed', error);
|
|
});
|
|
}, milestoneReminderCheckIntervalMs);
|
|
};
|
|
|
|
const readBearerToken = (authorizationHeader?: string) => {
|
|
if (!authorizationHeader) {
|
|
return '';
|
|
}
|
|
|
|
const [scheme, token] = authorizationHeader.split(' ');
|
|
return scheme?.toLowerCase() === 'bearer' && token ? token.trim() : '';
|
|
};
|
|
|
|
const resolveAnyAuth = async (token: string) => {
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
return (await resolveSessionAuth(hashToken(token), token)) ?? resolveIntegrationTokenAuth(hashToken(token), token);
|
|
};
|
|
|
|
const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const token = readBearerToken(req.headers.authorization);
|
|
const auth = await resolveAnyAuth(token);
|
|
|
|
if (!auth) {
|
|
res.status(401).json({ error: 'Authentication required.' });
|
|
return;
|
|
}
|
|
|
|
req.auth = auth;
|
|
next();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
const requireSessionAuth = (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.auth) {
|
|
res.status(401).json({ error: 'Authentication required.' });
|
|
return;
|
|
}
|
|
|
|
if (req.auth.authType !== 'session') {
|
|
res.status(403).json({ error: 'This endpoint requires a browser session instead of an integration token.' });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
const requireWriteAccess = (req: Request, res: Response, next: NextFunction) => {
|
|
if (req.auth?.authType === 'integration_token' && req.auth.integrationToken?.scope !== 'read_write') {
|
|
res.status(403).json({ error: 'That integration token is read-only.' });
|
|
return;
|
|
}
|
|
|
|
if (req.auth?.authType === 'session' && isAdminUser(req.auth.user)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (req.auth && !subscriptionAllowsWrite(req.auth.workspace)) {
|
|
res.status(402).json({
|
|
error:
|
|
req.auth.workspace.workspace_type === 'rescue'
|
|
? 'This rescue flock is read-only until FlockPal verifies it.'
|
|
: 'This flock is read-only until the subscription is restored.',
|
|
code: 'workspace_read_only',
|
|
});
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.auth) {
|
|
res.status(401).json({ error: 'Authentication required.' });
|
|
return;
|
|
}
|
|
|
|
if (!isAdminUser(req.auth.user)) {
|
|
res.status(403).json({ error: 'Admin access required.' });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
const requireWorkspaceRole = (allowedRoles: WorkspaceRole[]) => (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.auth) {
|
|
res.status(401).json({ error: 'Authentication required.' });
|
|
return;
|
|
}
|
|
|
|
if (!allowedRoles.includes(req.auth.membership.role)) {
|
|
res.status(403).json({ error: 'You do not have permission for that action.' });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
const isBillingOnlyWorkspaceUpdate = (
|
|
workspace: WorkspaceRow,
|
|
payload: z.infer<typeof workspaceSchema>,
|
|
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
|
|
|
|
app.get('/api/health', (_req: Request, res: Response) => {
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, res: Response) => {
|
|
const parsed = lostBirdReportSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid found bird report', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const matches = await findBirdsByBandId(parsed.data.tagId);
|
|
let deliveredCount = 0;
|
|
|
|
for (const bird of matches) {
|
|
try {
|
|
const recipients = await listWorkspaceNotificationEmails(bird.workspace_id);
|
|
const delivery = await sendLostBirdReportNotification({
|
|
bird,
|
|
recipients,
|
|
report: parsed.data,
|
|
});
|
|
|
|
if (delivery.delivered) {
|
|
deliveredCount += 1;
|
|
}
|
|
} catch (error) {
|
|
console.error('Lost bird notification failed', error);
|
|
}
|
|
}
|
|
|
|
if (!matches.length) {
|
|
res.json({
|
|
status: 'not_found',
|
|
message: 'That band ID is not currently in the FlockPal system.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (deliveredCount > 0) {
|
|
res.json({
|
|
status: 'contacted',
|
|
message: 'A matching bird was found and the flock contacts were notified.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.status(503).json({
|
|
status: 'not_contacted',
|
|
error: 'A matching bird was found, but FlockPal could not notify the flock right now. Please contact FlockPal support.',
|
|
});
|
|
} catch (error) {
|
|
console.error('Lost bird report handling failed', error);
|
|
res.status(500).json({ error: 'Unable to process this found bird report right now.' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
|
res.json({
|
|
providers: Object.values(oauthProviders).map((provider) => ({
|
|
providerKey: provider.providerKey,
|
|
displayName: provider.displayName,
|
|
enabled: Boolean(provider.clientId && provider.clientSecret),
|
|
})),
|
|
});
|
|
});
|
|
|
|
app.post('/api/auth/register', (_req: Request, res: Response) => {
|
|
res.status(410).json({ error: 'Password-based registration is disabled. Use a magic link or an identity provider.' });
|
|
});
|
|
|
|
app.post('/api/auth/login', (_req: Request, res: Response) => {
|
|
res.status(410).json({ error: 'Password-based sign-in is disabled. Use a magic link or an identity provider.' });
|
|
});
|
|
|
|
app.post('/api/auth/magic-link/request', async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = magicLinkRequestSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid magic link payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
const email = normalizeEmail(parsed.data.email);
|
|
const name = emptyToNull(parsed.data.name);
|
|
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
|
|
|
|
try {
|
|
const delivery = await issueMagicLinkInvite({
|
|
email,
|
|
name,
|
|
redirectTo,
|
|
});
|
|
|
|
res.status(202).json({
|
|
ok: true,
|
|
message: 'If that address can sign in, a magic link is on the way.',
|
|
previewUrl: delivery.previewUrl,
|
|
delivery: delivery.delivered ? 'email' : 'preview',
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => {
|
|
const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
|
|
|
|
if (!rawToken) {
|
|
res.status(400).send('Missing magic link token.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const magicLink = await consumeMagicLinkToken(hashToken(rawToken));
|
|
|
|
if (!magicLink) {
|
|
res.status(400).send('That sign-in link is invalid or expired.');
|
|
return;
|
|
}
|
|
|
|
let user = await findUserByEmail(magicLink.email);
|
|
|
|
if (!user) {
|
|
user = await createUser(magicLink.email, magicLink.name || magicLink.email.split('@')[0] || 'FlockPal User');
|
|
} else if (magicLink.name && !user.name.trim()) {
|
|
user = await updateUserName(user.id, magicLink.name);
|
|
}
|
|
|
|
await claimWorkspaceInvites(user!);
|
|
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
|
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
|
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
|
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
|
|
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
|
const redirectUrl = new URL(magicLink.redirect_to || frontendBaseUrl);
|
|
redirectUrl.searchParams.set('auth_token', token);
|
|
res.redirect(redirectUrl.toString());
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/logout', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
await deleteAuthSession(req.auth!.session.id);
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/session', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
res.json({
|
|
token: req.auth?.token,
|
|
session: await buildSessionPayload(req.auth!),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = switchWorkspaceSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid flock selection payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const membership = await getMembershipForUser(req.auth!.user.id, parsed.data.workspaceId);
|
|
|
|
if (!membership) {
|
|
res.status(403).json({ error: 'You do not have access to that flock.' });
|
|
return;
|
|
}
|
|
|
|
await updateSessionWorkspace(req.auth!.session.id, parsed.data.workspaceId);
|
|
|
|
const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token);
|
|
|
|
if (!updatedAuth) {
|
|
throw new Error('Unable to reload session.');
|
|
}
|
|
|
|
res.json({
|
|
token: req.auth!.token,
|
|
session: await buildSessionPayload(updatedAuth),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/oauth/:provider/start', async (req: Request, res: Response, next: NextFunction) => {
|
|
const providerKey = req.params.provider as ProviderKey;
|
|
const provider = oauthProviders[providerKey];
|
|
|
|
if (!provider) {
|
|
res.status(404).json({ error: 'Unknown authentication provider.' });
|
|
return;
|
|
}
|
|
|
|
if (!provider.clientId || !provider.clientSecret) {
|
|
res.status(400).json({ error: `${provider.displayName} login is not configured.` });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stateId = createRandomId();
|
|
const codeVerifier = createCodeVerifier();
|
|
const codeChallenge = createCodeChallenge(codeVerifier);
|
|
const redirectTo = typeof req.query.redirectTo === 'string' && req.query.redirectTo.trim() ? req.query.redirectTo : frontendBaseUrl;
|
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`;
|
|
|
|
await createOAuthState(stateId, providerKey, codeVerifier, redirectTo, expiresAt);
|
|
|
|
const authorizationUrl = new URL(provider.authorizationEndpoint);
|
|
authorizationUrl.searchParams.set('client_id', provider.clientId);
|
|
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
|
authorizationUrl.searchParams.set('response_type', 'code');
|
|
authorizationUrl.searchParams.set('scope', provider.scopes);
|
|
authorizationUrl.searchParams.set('state', stateId);
|
|
if (providerKey === 'apple') {
|
|
authorizationUrl.searchParams.set('response_mode', 'form_post');
|
|
} else {
|
|
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
|
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
}
|
|
|
|
res.redirect(authorizationUrl.toString());
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
const handleOAuthCallback = async (req: Request, res: Response, next: NextFunction) => {
|
|
const providerKey = req.params.provider as ProviderKey;
|
|
const provider = oauthProviders[providerKey];
|
|
|
|
if (!provider) {
|
|
res.status(404).send('Unknown authentication provider.');
|
|
return;
|
|
}
|
|
|
|
const code = typeof req.query.code === 'string' ? req.query.code : typeof req.body.code === 'string' ? req.body.code : '';
|
|
const state = typeof req.query.state === 'string' ? req.query.state : typeof req.body.state === 'string' ? req.body.state : '';
|
|
|
|
if (!code || !state) {
|
|
res.status(400).send('Missing OAuth callback parameters.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const oauthState = await consumeOAuthState(state, providerKey);
|
|
|
|
if (!oauthState) {
|
|
res.status(400).send('OAuth session is invalid or expired.');
|
|
return;
|
|
}
|
|
|
|
const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`;
|
|
const tokenBody = new URLSearchParams({
|
|
client_id: provider.clientId,
|
|
client_secret: provider.clientSecret,
|
|
code,
|
|
grant_type: 'authorization_code',
|
|
redirect_uri: redirectUri,
|
|
});
|
|
|
|
if (providerKey !== 'apple') {
|
|
tokenBody.set('code_verifier', oauthState.code_verifier);
|
|
}
|
|
|
|
const tokenResponse = await fetch(provider.tokenEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: tokenBody,
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
throw new Error(`Unable to complete ${provider.displayName} login.`);
|
|
}
|
|
|
|
const tokenJson = (await tokenResponse.json()) as { access_token?: string; id_token?: string };
|
|
const accessToken = tokenJson.access_token ?? '';
|
|
const idToken = tokenJson.id_token ?? '';
|
|
|
|
if (!accessToken && providerKey !== 'apple') {
|
|
throw new Error(`Unable to complete ${provider.displayName} login.`);
|
|
}
|
|
|
|
let providerSubject = '';
|
|
let email = '';
|
|
let name = '';
|
|
|
|
if (providerKey === 'apple') {
|
|
const claims = parseJwtPayload<{ sub?: string; email?: string }>(idToken);
|
|
const bodyUser = typeof req.body.user === 'string' ? (JSON.parse(req.body.user) as { name?: { firstName?: string; lastName?: string } }) : null;
|
|
providerSubject = String(claims.sub ?? '');
|
|
email = normalizeEmail(String(claims.email ?? ''));
|
|
name = [bodyUser?.name?.firstName ?? '', bodyUser?.name?.lastName ?? ''].join(' ').trim() || email.split('@')[0] || 'User';
|
|
} else {
|
|
const userInfoResponse = await fetch(provider.userinfoEndpoint, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!userInfoResponse.ok) {
|
|
throw new Error(`Unable to read ${provider.displayName} profile.`);
|
|
}
|
|
|
|
const userInfo = (await userInfoResponse.json()) as Record<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.post(
|
|
'/api/billing/checkout-session',
|
|
requireAuth,
|
|
requireSessionAuth,
|
|
requireWorkspaceRole(['owner', 'assistant']),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = z.object({ billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional() }).safeParse(req.body ?? {});
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const workspace = req.auth!.workspace;
|
|
|
|
if (workspace.workspace_type === 'rescue') {
|
|
res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' });
|
|
return;
|
|
}
|
|
|
|
const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan;
|
|
const billingInterval = parsed.data.billingInterval ?? workspace.billing_interval;
|
|
const priceId = getStripePriceIdForBillingPlan(billingPlan, billingInterval);
|
|
let stripeCustomerId = workspace.stripe_customer_id;
|
|
|
|
if (!stripeCustomerId) {
|
|
const customer = await getStripeClient().customers.create({
|
|
email: workspace.billing_email ?? req.auth!.user.email,
|
|
name: workspace.name,
|
|
metadata: {
|
|
workspaceId: String(workspace.id),
|
|
userId: req.auth!.user.id,
|
|
},
|
|
});
|
|
stripeCustomerId = customer.id;
|
|
await setWorkspaceStripeCustomerId(workspace.id, stripeCustomerId);
|
|
}
|
|
|
|
const checkoutSession = await getStripeClient().checkout.sessions.create({
|
|
mode: 'subscription',
|
|
customer: stripeCustomerId,
|
|
client_reference_id: String(workspace.id),
|
|
line_items: [{ price: priceId, quantity: 1 }],
|
|
success_url: stripeCheckoutSuccessUrl,
|
|
cancel_url: stripeCheckoutCancelUrl,
|
|
allow_promotion_codes: true,
|
|
metadata: {
|
|
workspaceId: String(workspace.id),
|
|
billingPlan,
|
|
billingInterval,
|
|
},
|
|
subscription_data: {
|
|
metadata: {
|
|
workspaceId: String(workspace.id),
|
|
billingPlan,
|
|
billingInterval,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!checkoutSession.url) {
|
|
throw new Error('Stripe did not return a checkout URL.');
|
|
}
|
|
|
|
res.json({ url: checkoutSession.url });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
'/api/billing/portal-session',
|
|
requireAuth,
|
|
requireSessionAuth,
|
|
requireWorkspaceRole(['owner', 'assistant']),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const workspace = req.auth!.workspace;
|
|
|
|
if (workspace.workspace_type === 'rescue') {
|
|
res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' });
|
|
return;
|
|
}
|
|
|
|
if (!workspace.stripe_customer_id) {
|
|
res.status(409).json({ error: 'Start a subscription before opening the billing portal.' });
|
|
return;
|
|
}
|
|
|
|
const portalSession = await getStripeClient().billingPortal.sessions.create({
|
|
customer: workspace.stripe_customer_id,
|
|
return_url: stripePortalReturnUrl,
|
|
});
|
|
|
|
res.json({ url: portalSession.url });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
|
res.json({ integrationTokens: tokens.map(normalizeIntegrationToken) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = integrationTokenCreateSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid integration token payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const rawToken = createIntegrationToken();
|
|
const expiresAt = parsed.data.expiresInDays
|
|
? new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000).toISOString()
|
|
: null;
|
|
const integrationToken = await createIntegrationTokenRecord({
|
|
userId: req.auth!.user.id,
|
|
workspaceId: req.auth!.workspace.id,
|
|
name: parsed.data.name,
|
|
tokenHash: hashToken(rawToken),
|
|
tokenPrefix: rawToken.slice(0, 16),
|
|
scope: parsed.data.scope,
|
|
expiresAt,
|
|
});
|
|
|
|
res.status(201).json({
|
|
integrationToken: normalizeIntegrationToken(integrationToken!),
|
|
token: rawToken,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id);
|
|
|
|
if (!revoked) {
|
|
res.status(404).json({ error: 'Integration token not found.' });
|
|
return;
|
|
}
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
res.json({
|
|
workspaces: await normalizeWorkspaceMembershipList(req.auth!.user.id),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = createWorkspaceSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const workspaceId = await getNextWorkspaceId();
|
|
const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan);
|
|
const workspace = await createWorkspace({
|
|
id: workspaceId,
|
|
name: parsed.data.name,
|
|
workspaceType: parsed.data.workspaceType,
|
|
billingEmail: emptyToNull(parsed.data.billingEmail),
|
|
billingPlan,
|
|
billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? 'monthly'),
|
|
owner: req.auth!.user,
|
|
});
|
|
|
|
if (workspace?.workspace_type === 'rescue') {
|
|
await sendRescueStatusNotification({
|
|
workspace,
|
|
ownerEmail: req.auth!.user.email,
|
|
event: 'created',
|
|
});
|
|
}
|
|
|
|
res.status(201).json({ workspace: normalizeWorkspace(workspace!) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/workspace', requireAuth, async (req: Request, res: Response) => {
|
|
res.json({ workspace: normalizeWorkspace(req.auth!.workspace) });
|
|
});
|
|
|
|
app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = workspaceSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid flock payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentWorkspace = req.auth!.workspace;
|
|
const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? currentWorkspace.billing_plan);
|
|
const billingInterval = parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? currentWorkspace.billing_interval);
|
|
const canUpdateWorkspace =
|
|
isAdminUser(req.auth!.user) ||
|
|
subscriptionAllowsWrite(currentWorkspace) ||
|
|
isBillingOnlyWorkspaceUpdate(currentWorkspace, parsed.data);
|
|
|
|
if (!canUpdateWorkspace) {
|
|
res.status(402).json({
|
|
error:
|
|
currentWorkspace.workspace_type === 'rescue'
|
|
? 'This rescue flock is read-only until FlockPal verifies it.'
|
|
: 'This flock is read-only until the subscription is restored.',
|
|
code: 'workspace_read_only',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const workspace = await updateWorkspace({
|
|
workspaceId: currentWorkspace.id,
|
|
name: parsed.data.name,
|
|
workspaceType: parsed.data.workspaceType,
|
|
billingEmail: emptyToNull(parsed.data.billingEmail),
|
|
billingPlan,
|
|
billingInterval,
|
|
});
|
|
|
|
if (workspace?.workspace_type === 'rescue' && currentWorkspace.workspace_type !== 'rescue') {
|
|
await sendRescueStatusNotification({
|
|
workspace,
|
|
ownerEmail: req.auth!.user.email,
|
|
event: 'converted',
|
|
});
|
|
}
|
|
|
|
res.json({ workspace: normalizeWorkspace(workspace!) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
if ((await getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) {
|
|
res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' });
|
|
return;
|
|
}
|
|
|
|
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
|
|
|
if (!nextWorkspaceId) {
|
|
const fallbackWorkspaceId = await getNextWorkspaceId();
|
|
const fallbackWorkspace = await createWorkspace({
|
|
id: fallbackWorkspaceId,
|
|
name: `${req.auth!.user.name}'s Flock`,
|
|
workspaceType: 'standard',
|
|
billingEmail: req.auth!.user.email,
|
|
billingPlan: 'household_basic',
|
|
billingInterval: 'monthly',
|
|
owner: req.auth!.user,
|
|
});
|
|
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
|
|
}
|
|
|
|
await updateSessionWorkspace(req.auth!.session.id, nextWorkspaceId);
|
|
|
|
const deletion = await deleteWorkspaceIfEmpty(req.auth!.workspace.id);
|
|
|
|
if (!deletion.deleted) {
|
|
await updateSessionWorkspace(req.auth!.session.id, req.auth!.workspace.id);
|
|
|
|
res.status(404).json({ error: 'Flock not found.' });
|
|
return;
|
|
}
|
|
|
|
const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token);
|
|
|
|
if (!updatedAuth) {
|
|
throw new Error('Unable to reload session.');
|
|
}
|
|
|
|
res.json({
|
|
deletedWorkspaceId: req.auth!.workspace.id,
|
|
token: req.auth!.token,
|
|
session: await buildSessionPayload(updatedAuth),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post(
|
|
'/api/workspace/rescue-status/cancel',
|
|
requireAuth,
|
|
requireSessionAuth,
|
|
requireWorkspaceRole(['owner', 'assistant']),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const workspace = await cancelRescueVerificationRequest(req.auth!.workspace.id);
|
|
|
|
if (!workspace) {
|
|
res.status(409).json({ error: 'Only pending rescue status requests can be canceled.' });
|
|
return;
|
|
}
|
|
|
|
await sendRescueStatusNotification({
|
|
workspace,
|
|
ownerEmail: req.auth!.user.email,
|
|
event: 'canceled',
|
|
});
|
|
|
|
res.json({ workspace: normalizeWorkspace(workspace) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get('/api/workspace/members', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const members = await listWorkspaceMembers(req.auth!.workspace.id);
|
|
res.json({ members: members.map(normalizeWorkspaceMember) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = workspaceMemberSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid flock member payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const inviteEmail = normalizeEmail(parsed.data.email);
|
|
const existingUser = await findUserByEmail(inviteEmail);
|
|
const member = await upsertWorkspaceMember({
|
|
workspaceId: req.auth!.workspace.id,
|
|
inviteEmail,
|
|
name: parsed.data.name,
|
|
role: parsed.data.role,
|
|
existingUser,
|
|
});
|
|
|
|
res.status(201).json({ member: normalizeWorkspaceMember(member!) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
|
|
|
|
if (!deleted) {
|
|
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
|
return;
|
|
}
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const birds = await listBirds(req.auth!.workspace.id);
|
|
res.json({ birds: birds.map(normalizeBird) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = birdSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await createBird({
|
|
workspaceId: req.auth!.workspace.id,
|
|
name: parsed.data.name,
|
|
tagId: parsed.data.tagId,
|
|
species: parsed.data.species,
|
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
|
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
|
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
|
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
|
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
|
});
|
|
|
|
res.status(201).json({ bird: normalizeBird(bird!) });
|
|
} catch (error) {
|
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
|
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
|
return;
|
|
}
|
|
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = flockTransferSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid flock transfer payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
|
|
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!sourceBird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id);
|
|
|
|
if (!targetWorkspaces.length) {
|
|
await createPendingBirdTransfer({
|
|
birdId: sourceBird.id,
|
|
sourceWorkspaceId: req.auth!.workspace.id,
|
|
destinationOwnerEmail,
|
|
requestedByUserId: req.auth!.user.id,
|
|
});
|
|
|
|
const delivery = await issueBirdTransferInvite({
|
|
email: destinationOwnerEmail,
|
|
birdName: sourceBird.name,
|
|
sourceWorkspaceName: req.auth!.workspace.name,
|
|
redirectTo: frontendBaseUrl,
|
|
});
|
|
|
|
res.status(202).json({
|
|
ok: true,
|
|
bird: normalizeBird(sourceBird),
|
|
destinationOwnerEmail,
|
|
inviteSent: true,
|
|
invitePreviewUrl: delivery.previewUrl,
|
|
inviteDelivery: delivery.delivered ? 'email' : 'preview',
|
|
message:
|
|
'A bird transfer invite was sent. The bird will stay in this flock until the recipient signs in, then FlockPal will automatically move it to their receiving flock.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (targetWorkspaces.length > 1) {
|
|
res.status(409).json({ error: 'That owner email has more than one flock. Ask the receiving owner to use a unique owner email before transferring.' });
|
|
return;
|
|
}
|
|
|
|
const targetWorkspace = targetWorkspaces[0];
|
|
const bird = await transferBirdToWorkspace(req.params.birdId, req.auth!.workspace.id, targetWorkspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
|
} catch (error) {
|
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
|
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
|
|
return;
|
|
}
|
|
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = birdSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid bird payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await updateBird({
|
|
birdId: req.params.birdId,
|
|
workspaceId: req.auth!.workspace.id,
|
|
name: parsed.data.name,
|
|
tagId: parsed.data.tagId,
|
|
species: parsed.data.species,
|
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
|
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
|
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
|
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
|
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
|
});
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
res.json({ bird: normalizeBird(bird) });
|
|
} catch (error) {
|
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
|
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
|
return;
|
|
}
|
|
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!deleted) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425);
|
|
const weights = await listWeightsForBird(req.params.birdId, req.auth!.workspace.id, days);
|
|
res.json({ weights: weights.map(normalizeWeight) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = weightSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
|
res.status(201).json({ weight: normalizeWeight(weight!) });
|
|
} catch (error) {
|
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
|
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
|
return;
|
|
}
|
|
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
|
|
res.json({ vetVisits: vetVisits.map(normalizeVetVisit) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = vetVisitSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const vetVisit = await createVetVisitForBird(
|
|
req.params.birdId,
|
|
parsed.data.visitedOn,
|
|
parsed.data.clinicName,
|
|
parsed.data.reason,
|
|
emptyToNull(parsed.data.notes),
|
|
);
|
|
|
|
res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = vetVisitSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid vet visit payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const vetVisit = await updateVetVisitForBird(
|
|
req.params.visitId,
|
|
req.params.birdId,
|
|
parsed.data.visitedOn,
|
|
parsed.data.clinicName,
|
|
parsed.data.reason,
|
|
emptyToNull(parsed.data.notes),
|
|
);
|
|
|
|
if (!vetVisit) {
|
|
res.status(404).json({ error: 'Vet visit not found.' });
|
|
return;
|
|
}
|
|
|
|
res.json({ vetVisit: normalizeVetVisit(vetVisit) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId);
|
|
|
|
if (!deleted) {
|
|
res.status(404).json({ error: 'Vet visit not found.' });
|
|
return;
|
|
}
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/birds/:birdId/medications', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const medications = await listMedicationsForBird(req.params.birdId, req.auth!.workspace.id);
|
|
res.json({ medications: medications.map(normalizeMedication) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = medicationSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid medication payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const medication = await createMedicationForBird(
|
|
req.params.birdId,
|
|
parsed.data.name,
|
|
parsed.data.dosage,
|
|
parsed.data.frequency,
|
|
parsed.data.doseSchedule,
|
|
emptyToNull(parsed.data.route),
|
|
parsed.data.startDate,
|
|
emptyToNull(parsed.data.endDate),
|
|
emptyToNull(parsed.data.notes),
|
|
);
|
|
|
|
res.status(201).json({ medication: normalizeMedication(medication!) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = medicationSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid medication payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const medication = await updateMedicationForBird(
|
|
req.params.medicationId,
|
|
req.params.birdId,
|
|
parsed.data.name,
|
|
parsed.data.dosage,
|
|
parsed.data.frequency,
|
|
parsed.data.doseSchedule,
|
|
emptyToNull(parsed.data.route),
|
|
parsed.data.startDate,
|
|
emptyToNull(parsed.data.endDate),
|
|
emptyToNull(parsed.data.notes),
|
|
);
|
|
|
|
if (!medication) {
|
|
res.status(404).json({ error: 'Medication not found.' });
|
|
return;
|
|
}
|
|
|
|
res.json({ medication: normalizeMedication(medication) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
|
|
if (!bird) {
|
|
res.status(404).json({ error: 'Bird not found.' });
|
|
return;
|
|
}
|
|
|
|
const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId);
|
|
|
|
if (!deleted) {
|
|
res.status(404).json({ error: 'Medication not found.' });
|
|
return;
|
|
}
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/birds/:birdId/medication-administrations', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const administrations = await listMedicationAdministrationsForBird(req.params.birdId, req.auth!.workspace.id);
|
|
res.json({ administrations: administrations.map(normalizeMedicationAdministration) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/birds/:birdId/medications/:medicationId/administrations', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = medicationAdministrationSchema.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid medication administration payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const administration = await upsertMedicationAdministrationForBird(
|
|
req.params.medicationId,
|
|
req.params.birdId,
|
|
req.auth!.workspace.id,
|
|
parsed.data.administeredOn,
|
|
parsed.data.administrationSlot,
|
|
parsed.data.status,
|
|
emptyToNull(parsed.data.notes),
|
|
req.auth!.user.id,
|
|
);
|
|
|
|
if (!administration) {
|
|
res.status(404).json({ error: 'Medication not found.' });
|
|
return;
|
|
}
|
|
|
|
res.status(201).json({ administration: normalizeMedicationAdministration(administration) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/reminders/bird-milestones/run', requireAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
|
const parsed = z
|
|
.object({
|
|
runDate: dateStringSchema.optional(),
|
|
})
|
|
.safeParse(req.body ?? {});
|
|
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Invalid reminder run payload', details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await runBirdMilestoneReminders(parsed.data.runDate ?? getDateInTimeZone());
|
|
res.json(result);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
console.error(error);
|
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
|
});
|
|
|
|
const start = async () => {
|
|
await ensureSchema();
|
|
app.listen(port, () => {
|
|
console.log(`FlockPal backend listening on port ${port}`);
|
|
});
|
|
startBirdMilestoneReminderScheduler();
|
|
};
|
|
|
|
start().catch((error) => {
|
|
console.error('Failed to start backend', error);
|
|
process.exit(1);
|
|
});
|