diff --git a/README.md b/README.md index 1b148e9..17afbf1 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,28 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean, ## Current scope +- Passwordless authentication only +- Magic-link email sign-in +- OAuth-ready login flow for Google, Microsoft, and Apple +- Multi-workspace model with `standard` household and `rescue` modes +- Shared workspace member management for both households and rescues +- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, and `household_plus` - Bird profiles with name, tag ID, and species +- Bird DOB and gotcha day fields - Daily weight recordings - 30-day weight graph +- Vet visit history with notes - Postgres-backed storage - React frontend and Express backend - Security-minded defaults like Helmet, CORS allow-listing, rate limiting, and input validation ## Planned next steps -- Vet visit history - Medication and care reminders -- Accounts, authorization, and role-based rescue access -- Billing and plan management for paid organizations with a free rescue tier +- Invitation acceptance and onboarding polish for workspace members +- Stripe or equivalent billing integration for paid household tiers +- Scheduled reminder delivery for birthdays, gotcha days, and care events +- Audit logging for workspace access changes and bird transfers ## Run locally @@ -30,6 +39,43 @@ docker compose up --build 3. Open `http://localhost:3000`. 4. The API health check is available at `http://localhost:5000/api/health`. +## Auth and workspace notes + +- One user can belong to multiple workspaces. +- A rescue member can also keep their own household flock in a separate workspace. +- Billing should attach to the household workspace, not the user account. +- Rescue workspaces stay on the free plan. +- Shared access is controlled by workspace roles like `owner`, `manager`, `staff`, and `viewer`. +- FlockPal no longer stores local passwords. +- Authentication now happens through magic links or external identity providers. + +## OAuth environment + +Set these in Docker or your `.env` file if you want provider login enabled: + +- `FRONTEND_URL` +- `BACKEND_URL` +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- `MICROSOFT_CLIENT_ID` +- `MICROSOFT_CLIENT_SECRET` +- `APPLE_CLIENT_ID` +- `APPLE_CLIENT_SECRET` + +## Magic-link email environment + +Set these if you want magic links delivered by email instead of logged as a preview URL during local development: + +- `SMTP_HOST` +- `SMTP_PORT` +- `SMTP_SECURE` +- `SMTP_USER` +- `SMTP_PASS` +- `SMTP_FROM_EMAIL` +- `SMTP_FROM_NAME` + ## Notes for monetization and security -This starter keeps the data model and deployment simple, but it is intentionally shaped so we can add authentication, organization scoping, audit trails, reminders, and Stripe-style billing later without redesigning the whole app. +This starter now includes the account and workspace foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch. + +For account design, `standard` vs `rescue` is best treated as a workspace type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than workspace roles such as `owner`, `manager`, `staff`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes. diff --git a/backend/package-lock.json b/backend/package-lock.json index 422d9d1..4b7ddd0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,12 +8,14 @@ "name": "flockpal-backend", "version": "0.1.0", "dependencies": { + "@types/nodemailer": "^8.0.0", "cors": "2.8.5", "dotenv": "16.4.5", "express": "4.21.2", "express-rate-limit": "7.5.0", "helmet": "8.1.0", "morgan": "1.10.0", + "nodemailer": "^8.0.5", "pg": "8.13.1", "zod": "3.24.1" }, @@ -513,12 +515,20 @@ "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.11.10", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", @@ -1239,6 +1249,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1808,7 +1827,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/backend/package.json b/backend/package.json index 6041067..89aa4ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,12 +9,14 @@ "start": "node dist/app.js" }, "dependencies": { + "@types/nodemailer": "^8.0.0", "cors": "2.8.5", "dotenv": "16.4.5", "express": "4.21.2", "express-rate-limit": "7.5.0", "helmet": "8.1.0", "morgan": "1.10.0", + "nodemailer": "^8.0.5", "pg": "8.13.1", "zod": "3.24.1" }, diff --git a/backend/src/app.ts b/backend/src/app.ts index 504b123..d483171 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,75 +1,89 @@ +import crypto from 'crypto'; import cors from 'cors'; import dotenv from 'dotenv'; import express, { type NextFunction, type Request, type Response } from 'express'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import morgan from 'morgan'; +import nodemailer from 'nodemailer'; import pg from 'pg'; import { z } from 'zod'; dotenv.config(); -const app = express(); -const port = Number(process.env.PORT ?? 5000); -const { Pool } = pg; -const pool = new Pool({ - host: process.env.POSTGRES_HOST ?? 'localhost', - port: Number(process.env.POSTGRES_PORT ?? 5432), - database: process.env.POSTGRES_DB ?? 'flockpal', - user: process.env.POSTGRES_USER ?? 'flockpal', - password: process.env.POSTGRES_PASSWORD ?? 'flockpal_dev_password', -}); +type WorkspaceType = 'standard' | 'rescue'; +type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; +type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus'; +type ProviderKey = 'google' | 'microsoft' | 'apple'; -const defaultAllowedOrigins = [ - 'http://localhost:3000', - 'http://127.0.0.1:3000', - 'http://localhost:5173', - 'http://127.0.0.1:5173', -]; +type UserRow = { + id: string; + email: string; + password_hash: string | null; + name: string; + created_at: string; +}; -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), - ), -); +type WorkspaceRow = { + id: number; + name: string; + workspace_type: WorkspaceType; + billing_email: string | null; + billing_plan: BillingPlan; + created_at: string; + updated_at: string; +}; -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); +type WorkspaceMemberRow = { + id: string; + workspace_id: number; + user_id: string | null; + invite_email: string; + name: string; + role: WorkspaceRole; + accepted_at: string | null; + created_at: string; +}; -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), - dateOfBirth: dateStringSchema.optional().or(z.literal('')), - gotchaDay: dateStringSchema.optional().or(z.literal('')), - chartColor: chartColorSchema.optional(), - photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')), -}); +type AuthSessionRow = { + id: string; + user_id: string; + active_workspace_id: number; + token_hash: string; + expires_at: string; + created_at: string; +}; -const weightSchema = z.object({ - weightGrams: z.coerce.number().positive().max(10000), - recordedOn: dateStringSchema, - notes: z.string().trim().max(280).optional().or(z.literal('')), -}); +type AuthAccountRow = { + id: string; + user_id: string; + provider_key: ProviderKey; + provider_subject: string; + provider_email: string | null; + created_at: string; +}; -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('')), -}); +type OAuthStateRow = { + id: string; + provider_key: ProviderKey; + code_verifier: string; + redirect_to: string; + expires_at: string; +}; + +type MagicLinkTokenRow = { + id: string; + email: string; + name: string | null; + token_hash: string; + redirect_to: string; + expires_at: string; + created_at: string; +}; type BirdRow = { id: string; + workspace_id: number; name: string; tag_id: string; species: string; @@ -77,6 +91,8 @@ type BirdRow = { gotcha_day: string | null; chart_color: string; photo_data_url: string | null; + notify_on_dob: boolean; + notify_on_gotcha_day: boolean; created_at: string; latest_weight_grams: string | null; latest_recorded_on: string | null; @@ -99,6 +115,274 @@ type VetVisitRow = { notes: string | null; }; +type AuthContext = { + user: UserRow; + session: AuthSessionRow; + workspace: WorkspaceRow; + membership: WorkspaceMemberRow; + token: string; +}; + +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 { Pool } = pg; +const pool = new Pool({ + host: process.env.POSTGRES_HOST ?? 'localhost', + port: Number(process.env.POSTGRES_PORT ?? 5432), + database: process.env.POSTGRES_DB ?? 'flockpal', + user: process.env.POSTGRES_USER ?? 'flockpal', + password: process.env.POSTGRES_PASSWORD ?? 'flockpal_dev_password', +}); + +const defaultAllowedOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173']; + +const allowedOrigins = Array.from( + new Set( + [process.env.FRONTEND_URL, process.env.FRONTEND_URLS] + .filter(Boolean) + .flatMap((value) => (value ?? '').split(',')) + .map((origin) => origin.trim()) + .filter(Boolean) + .concat(defaultAllowedOrigins), + ), +); + +const dateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const chartColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/); +const photoDataUrlSchema = z + .string() + .regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/) + .max(1_500_000); + +const magicLinkRequestSchema = z.object({ + name: z.string().trim().max(160).optional().or(z.literal('')), + email: z.string().trim().email().max(255), + redirectTo: z.string().trim().url().max(2000).optional().or(z.literal('')), +}); + +const switchWorkspaceSchema = z.object({ + workspaceId: z.coerce.number().int().positive(), +}); + +const workspaceTypeSchema = z.enum(['standard', 'rescue']); +const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']); +const billingPlanSchema = z.enum(['household_basic', 'household_plus']); + +const workspaceSchema = z.object({ + name: z.string().trim().min(1).max(160), + workspaceType: workspaceTypeSchema, + billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), + billingPlan: billingPlanSchema.optional(), +}); + +const createWorkspaceSchema = z.object({ + name: z.string().trim().min(1).max(160), + workspaceType: workspaceTypeSchema, + billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), + billingPlan: billingPlanSchema.optional(), +}); + +const workspaceMemberSchema = z.object({ + name: z.string().trim().min(1).max(160), + email: z.string().trim().email().max(255), + role: workspaceRoleSchema, +}); + +const 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), + 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 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 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') => { + if (workspaceType === 'rescue') { + return 'rescue_free' as const; + } + + 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 mailTransport = + smtpHost && smtpFromEmail + ? nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: smtpUser && smtpPass ? { user: smtpUser, pass: smtpPass } : undefined, + }) + : null; + +const parseJwtPayload = >(token: string) => { + const segments = token.split('.'); + if (segments.length < 2) { + throw new Error('Invalid token payload.'); + } + + return JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8')) as T; +}; + +const normalizeUser = (row: UserRow) => ({ + id: row.id, + email: row.email, + name: row.name, + createdAt: row.created_at, +}); + +const normalizeWorkspace = (row: WorkspaceRow) => ({ + id: row.id, + name: row.name, + workspaceType: row.workspace_type, + billingEmail: row.billing_email, + billingPlan: row.billing_plan, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +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, + 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 birdSelectFields = ` + birds.id, + birds.workspace_id, + birds.name, + birds.tag_id, + birds.species, + birds.date_of_birth::text, + birds.gotcha_day::text, + birds.chart_color, + birds.photo_data_url, + birds.notify_on_dob, + birds.notify_on_gotcha_day, + birds.created_at, + latest.weight_grams AS latest_weight_grams, + latest.recorded_on::text AS latest_recorded_on +`; + +const oauthProviders = { + google: { + providerKey: 'google' as const, + displayName: 'Google', + clientId: process.env.GOOGLE_CLIENT_ID ?? '', + clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '', + authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo', + scopes: 'openid email profile', + }, + microsoft: { + providerKey: 'microsoft' as const, + displayName: 'Microsoft', + clientId: process.env.MICROSOFT_CLIENT_ID ?? '', + clientSecret: process.env.MICROSOFT_CLIENT_SECRET ?? '', + authorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo', + scopes: 'openid email profile User.Read', + }, + apple: { + providerKey: 'apple' as const, + displayName: 'Apple', + clientId: process.env.APPLE_CLIENT_ID ?? '', + clientSecret: process.env.APPLE_CLIENT_SECRET ?? '', + authorizationEndpoint: 'https://appleid.apple.com/auth/authorize', + tokenEndpoint: 'https://appleid.apple.com/auth/token', + userinfoEndpoint: '', + scopes: 'name email', + }, +}; + app.use(helmet({ crossOriginResourcePolicy: false })); app.use( cors({ @@ -121,79 +405,140 @@ app.use( }), ); app.use(express.json({ limit: '2mb' })); +app.use(express.urlencoded({ extended: false })); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); -const emptyToNull = (value?: string) => { - const trimmed = value?.trim() ?? ''; - return trimmed ? trimmed : null; -}; - -const normalizeBird = (row: BirdRow) => ({ - id: row.id, - name: row.name, - tagId: row.tag_id, - species: row.species, - dateOfBirth: row.date_of_birth, - gotchaDay: row.gotcha_day, - chartColor: row.chart_color, - photoDataUrl: row.photo_data_url, - createdAt: row.created_at, - latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null, - latestRecordedOn: row.latest_recorded_on, -}); - -const birdSelectFields = ` - birds.id, - birds.name, - birds.tag_id, - birds.species, - birds.date_of_birth::text, - birds.gotcha_day::text, - birds.chart_color, - birds.photo_data_url, - birds.created_at, - latest.weight_grams AS latest_weight_grams, - latest.recorded_on::text AS 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 ensureSchema = async () => { await pool.query(` CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255), + name VARCHAR(160) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY, + name VARCHAR(160) NOT NULL DEFAULT 'My Flock', + workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard', + billing_email VARCHAR(255), + billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + ALTER TABLE workspaces + DROP CONSTRAINT IF EXISTS workspaces_id_check; + + ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic'; + + INSERT INTO workspaces (id, name, workspace_type, billing_plan) + VALUES (1, 'My Flock', 'standard', 'household_basic') + ON CONFLICT (id) DO NOTHING; + + CREATE TABLE IF NOT EXISTS workspace_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + invite_email VARCHAR(255) NOT NULL, + name VARCHAR(160) NOT NULL, + role VARCHAR(16) NOT NULL DEFAULT 'staff', + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_members_workspace_email + ON workspace_members (workspace_id, invite_email); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_members_workspace_user + ON workspace_members (workspace_id, user_id) + WHERE user_id IS NOT NULL; + + CREATE TABLE IF NOT EXISTS auth_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + active_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS auth_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_key VARCHAR(32) NOT NULL, + provider_subject VARCHAR(255) NOT NULL, + provider_email VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_accounts_provider_subject + ON auth_accounts (provider_key, provider_subject); + + CREATE TABLE IF NOT EXISTS oauth_states ( + id UUID PRIMARY KEY, + provider_key VARCHAR(32) NOT NULL, + code_verifier VARCHAR(255) NOT NULL, + redirect_to TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL + ); + + CREATE TABLE IF NOT EXISTS magic_link_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + name VARCHAR(160), + token_hash VARCHAR(255) NOT NULL UNIQUE, + redirect_to TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_email + ON magic_link_tokens (email, created_at DESC); + CREATE TABLE IF NOT EXISTS birds ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id INTEGER NOT NULL DEFAULT 1, name VARCHAR(120) NOT NULL, - tag_id VARCHAR(80) NOT NULL UNIQUE, + tag_id VARCHAR(80) NOT NULL, species VARCHAR(120) NOT NULL, date_of_birth DATE, gotcha_day DATE, chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', photo_data_url TEXT, + notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, + notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); ALTER TABLE birds + ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE, ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', - ADD COLUMN IF NOT EXISTS photo_data_url TEXT; + ADD COLUMN IF NOT EXISTS photo_data_url TEXT, + ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE; + + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'birds_workspace_fk') THEN + ALTER TABLE birds + ADD CONSTRAINT birds_workspace_fk + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + END IF; + END $$; + + ALTER TABLE birds + DROP CONSTRAINT IF EXISTS birds_tag_id_key; + + CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id + ON birds (workspace_id, tag_id); CREATE TABLE IF NOT EXISTS weight_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -223,7 +568,393 @@ const ensureSchema = async () => { `); }; -const getBirdById = async (birdId: string) => { +const getNextWorkspaceId = async () => { + const result = await pool.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); + return Number(result.rows[0]?.next_id ?? 1); +}; + +const getWorkspaceById = async (workspaceId: number) => { + const result = await pool.query( + `SELECT id, name, workspace_type, billing_email, billing_plan, created_at, updated_at + FROM workspaces + WHERE id = $1`, + [workspaceId], + ); + + return result.rows[0] ?? null; +}; + +const getMembershipForUser = async (userId: string, workspaceId: number) => { + const result = await pool.query( + `SELECT id, workspace_id, user_id, invite_email, name, role, accepted_at::text, created_at + FROM workspace_members + WHERE workspace_id = $1 + AND user_id = $2`, + [workspaceId, userId], + ); + + return result.rows[0] ?? null; +}; + +const listMembershipsForUser = async (userId: string) => { + const result = await pool.query< + WorkspaceMemberRow & { + workspace_name: string; + workspace_type: WorkspaceType; + billing_email: string | null; + billing_plan: BillingPlan; + workspace_created_at: string; + workspace_updated_at: string; + } + >( + `SELECT + workspace_members.id, + workspace_members.workspace_id, + workspace_members.user_id, + workspace_members.invite_email, + workspace_members.name, + workspace_members.role, + workspace_members.accepted_at::text, + workspace_members.created_at, + workspaces.name AS workspace_name, + workspaces.workspace_type, + workspaces.billing_email, + workspaces.billing_plan, + workspaces.created_at AS workspace_created_at, + workspaces.updated_at AS workspace_updated_at + FROM workspace_members + INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id + WHERE workspace_members.user_id = $1 + ORDER BY workspaces.created_at ASC`, + [userId], + ); + + return result.rows.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, + created_at: row.workspace_created_at, + updated_at: row.workspace_updated_at, + }), + })); +}; + +const ensurePersonalWorkspaceForUser = async (user: UserRow) => { + const existing = await pool.query<{ workspace_id: number }>( + `SELECT workspace_id + FROM workspace_members + INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id + WHERE workspace_members.user_id = $1 + AND workspaces.workspace_type = 'standard' + ORDER BY workspaces.created_at ASC + LIMIT 1`, + [user.id], + ); + + if (existing.rowCount) { + return Number(existing.rows[0].workspace_id); + } + + const unclaimed = await pool.query<{ workspace_id: number }>( + `SELECT workspaces.id AS workspace_id + FROM workspaces + LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id + WHERE workspaces.id = 1 + GROUP BY workspaces.id + HAVING COUNT(workspace_members.id) = 0 + LIMIT 1`, + ); + + const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId(); + + if (!unclaimed.rowCount) { + await pool.query( + `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email) + VALUES ($1, $2, 'standard', 'household_basic', $3)`, + [workspaceId, `${user.name}'s Flock`, user.email], + ); + } else { + await pool.query( + `UPDATE workspaces + SET name = $2, + workspace_type = 'standard', + billing_plan = 'household_basic', + billing_email = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [workspaceId, `${user.name}'s Flock`, user.email], + ); + } + + await pool.query( + `INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) + VALUES ($1, $2, $3, $4, 'owner', CURRENT_TIMESTAMP) + ON CONFLICT (workspace_id, invite_email) DO UPDATE + SET user_id = EXCLUDED.user_id, + name = EXCLUDED.name, + role = 'owner', + accepted_at = CURRENT_TIMESTAMP`, + [workspaceId, user.id, user.email, user.name], + ); + + return workspaceId; +}; + +const claimWorkspaceInvites = async (user: UserRow) => { + await pool.query( + `UPDATE workspace_members + SET user_id = $1, + accepted_at = CURRENT_TIMESTAMP + WHERE LOWER(invite_email) = LOWER($2) + AND user_id IS NULL`, + [user.id, user.email], + ); +}; + +const createAuthSession = async (userId: string, activeWorkspaceId: number) => { + const token = createSessionToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + sessionDays); + + const result = await pool.query( + `INSERT INTO auth_sessions (user_id, active_workspace_id, token_hash, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, active_workspace_id, token_hash, expires_at::text, created_at`, + [userId, activeWorkspaceId, tokenHash, expiresAt.toISOString()], + ); + + return { + token, + session: result.rows[0], + }; +}; + +const buildSessionPayload = async (auth: AuthContext) => { + const memberships = await listMembershipsForUser(auth.user.id); + + return { + user: normalizeUser(auth.user), + activeWorkspace: normalizeWorkspace(auth.workspace), + activeMembership: normalizeWorkspaceMember(auth.membership), + workspaces: memberships, + providers: Object.values(oauthProviders).map((provider) => ({ + providerKey: provider.providerKey, + displayName: provider.displayName, + enabled: Boolean(provider.clientId && provider.clientSecret), + })), + }; +}; + +const sendMagicLink = async ({ + email, + name, + magicLinkUrl, +}: { + email: string; + name: string | null; + magicLinkUrl: string; +}) => { + if (!mailTransport) { + console.log(`Magic sign-in link for ${email}: ${magicLinkUrl}`); + return { + delivered: false, + previewUrl: magicLinkUrl, + }; + } + + await mailTransport.sendMail({ + from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, + to: email, + subject: 'Your FlockPal sign-in link', + text: [ + `Hi ${name || 'there'},`, + '', + 'Use this secure link to sign in to FlockPal:', + magicLinkUrl, + '', + 'This link expires in 15 minutes and can only be used once.', + ].join('\n'), + html: ` +

Hi ${name || 'there'},

+

Use this secure link to sign in to FlockPal:

+

Sign in to FlockPal

+

This link expires in 15 minutes and can only be used once.

+ `, + }); + + return { + delivered: true, + previewUrl: null, + }; +}; + +const readBearerToken = (authorizationHeader?: string) => { + if (!authorizationHeader) { + return ''; + } + + const [scheme, token] = authorizationHeader.split(' '); + return scheme?.toLowerCase() === 'bearer' && token ? token.trim() : ''; +}; + +const resolveAuth = async (token: string) => { + if (!token) { + return null; + } + + const result = await pool.query< + AuthSessionRow & + UserRow & + WorkspaceRow & + WorkspaceMemberRow & { + session_id: string; + session_user_id: string; + session_active_workspace_id: number; + session_token_hash: string; + session_expires_at: string; + session_created_at: string; + user_id_row: string; + user_email: string; + user_password_hash: string | null; + user_name: string; + user_created_at: string; + workspace_id_row: number; + workspace_name: string; + workspace_workspace_type: WorkspaceType; + workspace_billing_email: string | null; + workspace_billing_plan: BillingPlan; + workspace_created_at: string; + workspace_updated_at: string; + membership_id_row: string; + membership_workspace_id: number; + membership_user_id: string | null; + membership_invite_email: string; + membership_name: string; + membership_role: WorkspaceRole; + membership_accepted_at: string | null; + membership_created_at: string; + } + >( + `SELECT + auth_sessions.id AS session_id, + auth_sessions.user_id AS session_user_id, + auth_sessions.active_workspace_id AS session_active_workspace_id, + auth_sessions.token_hash AS session_token_hash, + auth_sessions.expires_at::text AS session_expires_at, + auth_sessions.created_at AS session_created_at, + users.id AS user_id_row, + users.email AS user_email, + users.password_hash AS user_password_hash, + users.name AS user_name, + users.created_at AS user_created_at, + workspaces.id AS workspace_id_row, + workspaces.name AS workspace_name, + workspaces.workspace_type AS workspace_workspace_type, + workspaces.billing_email AS workspace_billing_email, + workspaces.billing_plan AS workspace_billing_plan, + workspaces.created_at AS workspace_created_at, + workspaces.updated_at AS workspace_updated_at, + workspace_members.id AS membership_id_row, + workspace_members.workspace_id AS membership_workspace_id, + workspace_members.user_id AS membership_user_id, + workspace_members.invite_email AS membership_invite_email, + workspace_members.name AS membership_name, + workspace_members.role AS membership_role, + workspace_members.accepted_at::text AS membership_accepted_at, + workspace_members.created_at AS membership_created_at + FROM auth_sessions + INNER JOIN users ON users.id = auth_sessions.user_id + INNER JOIN workspaces ON workspaces.id = auth_sessions.active_workspace_id + INNER JOIN workspace_members + ON workspace_members.workspace_id = workspaces.id + AND workspace_members.user_id = users.id + WHERE auth_sessions.token_hash = $1 + AND auth_sessions.expires_at > CURRENT_TIMESTAMP`, + [hashToken(token)], + ); + + if (!result.rowCount) { + return null; + } + + const row = result.rows[0]; + + return { + user: { + id: row.user_id_row, + email: row.user_email, + password_hash: row.user_password_hash, + name: row.user_name, + created_at: row.user_created_at, + }, + session: { + id: row.session_id, + user_id: row.session_user_id, + active_workspace_id: row.session_active_workspace_id, + token_hash: row.session_token_hash, + expires_at: row.session_expires_at, + created_at: row.session_created_at, + }, + workspace: { + id: row.workspace_id_row, + name: row.workspace_name, + workspace_type: row.workspace_workspace_type, + billing_email: row.workspace_billing_email, + billing_plan: row.workspace_billing_plan, + created_at: row.workspace_created_at, + updated_at: row.workspace_updated_at, + }, + membership: { + id: row.membership_id_row, + workspace_id: row.membership_workspace_id, + user_id: row.membership_user_id, + invite_email: row.membership_invite_email, + name: row.membership_name, + role: row.membership_role, + accepted_at: row.membership_accepted_at, + created_at: row.membership_created_at, + }, + token, + } satisfies AuthContext; +}; + +const requireAuth = async (req: Request, res: Response, next: NextFunction) => { + try { + const token = readBearerToken(req.headers.authorization); + const auth = await resolveAuth(token); + + if (!auth) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + + req.auth = auth; + next(); + } catch (error) { + next(error); + } +}; + +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 getBirdById = async (birdId: string, workspaceId: number) => { const result = await pool.query( `SELECT ${birdSelectFields} @@ -235,8 +966,9 @@ const getBirdById = async (birdId: string) => { ORDER BY recorded_on DESC LIMIT 1 ) latest ON TRUE - WHERE birds.id = $1`, - [birdId], + WHERE birds.id = $1 + AND birds.workspace_id = $2`, + [birdId, workspaceId], ); return result.rows[0] ?? null; @@ -246,10 +978,548 @@ app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true }); }); -app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) => { +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 result = await pool.query(` - SELECT + await pool.query( + `DELETE FROM magic_link_tokens + WHERE expires_at <= CURRENT_TIMESTAMP`, + ); + + const rawToken = createSessionToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + + await pool.query( + `INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [email, name, tokenHash, redirectTo, expiresAt], + ); + + const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`); + verifyUrl.searchParams.set('token', rawToken); + + const delivery = await sendMagicLink({ + email, + name, + magicLinkUrl: verifyUrl.toString(), + }); + + 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 result = await pool.query( + `DELETE FROM magic_link_tokens + WHERE token_hash = $1 + AND expires_at > CURRENT_TIMESTAMP + RETURNING id, email, name, token_hash, redirect_to, expires_at::text, created_at`, + [hashToken(rawToken)], + ); + + const magicLink = result.rows[0]; + + if (!magicLink) { + res.status(400).send('That sign-in link is invalid or expired.'); + return; + } + + let userResult = await pool.query( + `SELECT id, email, password_hash, name, created_at + FROM users + WHERE email = $1`, + [magicLink.email], + ); + + let user = userResult.rows[0]; + + if (!user) { + const created = await pool.query( + `INSERT INTO users (email, name) + VALUES ($1, $2) + RETURNING id, email, password_hash, name, created_at`, + [magicLink.email, magicLink.name || magicLink.email.split('@')[0] || 'FlockPal User'], + ); + user = created.rows[0]; + } else if (magicLink.name && !user.name.trim()) { + const updated = await pool.query( + `UPDATE users + SET name = $2 + WHERE id = $1 + RETURNING id, email, password_hash, name, created_at`, + [user.id, magicLink.name], + ); + user = updated.rows[0]; + } + + await claimWorkspaceInvites(user); + const memberships = await listMembershipsForUser(user.id); + const activeWorkspaceId = memberships[0]?.workspace.id ?? (await ensurePersonalWorkspaceForUser(user)); + 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, async (req: Request, res: Response, next: NextFunction) => { + try { + await pool.query('DELETE FROM auth_sessions WHERE id = $1', [req.auth?.session.id]); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.get('/api/auth/session', requireAuth, 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, async (req: Request, res: Response, next: NextFunction) => { + const parsed = switchWorkspaceSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid workspace 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 workspace.' }); + return; + } + + await pool.query( + `UPDATE auth_sessions + SET active_workspace_id = $2 + WHERE id = $1`, + [req.auth!.session.id, parsed.data.workspaceId], + ); + + const updatedAuth = await resolveAuth(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 pool.query( + `INSERT INTO oauth_states (id, provider_key, code_verifier, redirect_to, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [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 stateResult = await pool.query( + `DELETE FROM oauth_states + WHERE id = $1 + AND provider_key = $2 + AND expires_at > CURRENT_TIMESTAMP + RETURNING id, provider_key, code_verifier, redirect_to, expires_at::text`, + [state, providerKey], + ); + + const oauthState = stateResult.rows[0]; + + if (!oauthState) { + res.status(400).send('OAuth session is invalid or expired.'); + return; + } + + const redirectUri = `${backendBaseUrl}/api/auth/oauth/${providerKey}/callback`; + const tokenBody = new URLSearchParams({ + client_id: provider.clientId, + client_secret: provider.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + if (providerKey !== 'apple') { + tokenBody.set('code_verifier', oauthState.code_verifier); + } + + const tokenResponse = await fetch(provider.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenBody, + }); + + if (!tokenResponse.ok) { + throw new Error(`Unable to complete ${provider.displayName} login.`); + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string; id_token?: string }; + const accessToken = tokenJson.access_token ?? ''; + const idToken = tokenJson.id_token ?? ''; + + if (!accessToken && providerKey !== 'apple') { + throw new Error(`Unable to complete ${provider.displayName} login.`); + } + + let providerSubject = ''; + let email = ''; + let name = ''; + + if (providerKey === 'apple') { + const claims = parseJwtPayload<{ sub?: string; email?: string }>(idToken); + const bodyUser = typeof req.body.user === 'string' ? JSON.parse(req.body.user) as { name?: { firstName?: string; lastName?: string } } : null; + providerSubject = String(claims.sub ?? ''); + email = normalizeEmail(String(claims.email ?? '')); + name = [bodyUser?.name?.firstName ?? '', bodyUser?.name?.lastName ?? ''].join(' ').trim() || email.split('@')[0] || 'User'; + } else { + const userInfoResponse = await fetch(provider.userinfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!userInfoResponse.ok) { + throw new Error(`Unable to read ${provider.displayName} profile.`); + } + + const userInfo = (await userInfoResponse.json()) as Record; + providerSubject = String(userInfo.sub ?? userInfo.id ?? ''); + email = normalizeEmail(String(userInfo.email ?? userInfo.preferred_username ?? '')); + name = String(userInfo.name ?? userInfo.given_name ?? email.split('@')[0] ?? 'User').trim(); + } + + if (!providerSubject || !email) { + throw new Error(`Unable to identify ${provider.displayName} account.`); + } + + let userResult = await pool.query( + `SELECT users.id, users.email, users.password_hash, users.name, users.created_at + FROM auth_accounts + INNER JOIN users ON users.id = auth_accounts.user_id + WHERE auth_accounts.provider_key = $1 + AND auth_accounts.provider_subject = $2`, + [providerKey, providerSubject], + ); + + if (!userResult.rowCount) { + userResult = await pool.query( + `SELECT id, email, password_hash, name, created_at + FROM users + WHERE email = $1`, + [email], + ); + } + + let user = userResult.rows[0]; + + if (!user) { + const created = await pool.query( + `INSERT INTO users (email, name) + VALUES ($1, $2) + RETURNING id, email, password_hash, name, created_at`, + [email, name], + ); + user = created.rows[0]; + } + + await pool.query( + `INSERT INTO auth_accounts (user_id, provider_key, provider_subject, provider_email) + VALUES ($1, $2, $3, $4) + ON CONFLICT (provider_key, provider_subject) DO NOTHING`, + [user.id, providerKey, providerSubject, email], + ); + + await claimWorkspaceInvites(user); + const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user); + 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/workspaces', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + res.json({ + workspaces: await listMembershipsForUser(req.auth!.user.id), + }); + } catch (error) { + next(error); + } +}); + +app.post('/api/workspaces', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + const parsed = createWorkspaceSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid workspace payload', details: parsed.error.flatten() }); + return; + } + + try { + const workspaceId = await getNextWorkspaceId(); + const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan); + + await pool.query( + `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan) + VALUES ($1, $2, $3, $4, $5)`, + [workspaceId, parsed.data.name, parsed.data.workspaceType, emptyToNull(parsed.data.billingEmail), billingPlan], + ); + + await pool.query( + `INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) + VALUES ($1, $2, $3, $4, 'owner', CURRENT_TIMESTAMP)`, + [workspaceId, req.auth!.user.id, req.auth!.user.email, req.auth!.user.name], + ); + + const workspace = await getWorkspaceById(workspaceId); + 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, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = workspaceSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid workspace payload', details: parsed.error.flatten() }); + return; + } + + try { + const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? req.auth!.workspace.billing_plan); + + const result = await pool.query( + `UPDATE workspaces + SET name = $2, + workspace_type = $3, + billing_email = $4, + billing_plan = $5, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, name, workspace_type, billing_email, billing_plan, created_at, updated_at`, + [req.auth!.workspace.id, parsed.data.name, parsed.data.workspaceType, emptyToNull(parsed.data.billingEmail), billingPlan], + ); + + res.json({ workspace: normalizeWorkspace(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.get('/api/workspace/members', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await pool.query( + `SELECT id, workspace_id, user_id, invite_email, name, role, accepted_at::text, created_at + FROM workspace_members + WHERE workspace_id = $1 + ORDER BY created_at ASC`, + [req.auth!.workspace.id], + ); + + res.json({ members: result.rows.map(normalizeWorkspaceMember) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/workspace/members', requireAuth, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = workspaceMemberSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid workspace member payload', details: parsed.error.flatten() }); + return; + } + + try { + const inviteEmail = normalizeEmail(parsed.data.email); + const existingUser = await pool.query( + `SELECT id, email, password_hash, name, created_at + FROM users + WHERE email = $1`, + [inviteEmail], + ); + + const existingUserRow = existingUser.rows[0]; + const result = await pool.query( + `INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (workspace_id, invite_email) DO UPDATE + SET name = EXCLUDED.name, + role = EXCLUDED.role, + user_id = COALESCE(workspace_members.user_id, EXCLUDED.user_id), + accepted_at = COALESCE(workspace_members.accepted_at, EXCLUDED.accepted_at) + RETURNING id, workspace_id, user_id, invite_email, name, role, accepted_at::text, created_at`, + [ + req.auth!.workspace.id, + existingUserRow?.id ?? null, + inviteEmail, + parsed.data.name, + parsed.data.role, + existingUserRow ? new Date().toISOString() : null, + ], + ); + + res.status(201).json({ member: normalizeWorkspaceMember(result.rows[0]) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/workspace/members/:memberId', requireAuth, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await pool.query<{ id: string }>( + `DELETE FROM workspace_members + WHERE id = $1 + AND workspace_id = $2 + AND role <> 'owner' + RETURNING id`, + [req.params.memberId, req.auth!.workspace.id], + ); + + if (!result.rowCount) { + res.status(404).json({ error: 'Workspace 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 result = await pool.query( + `SELECT ${birdSelectFields} FROM birds LEFT JOIN LATERAL ( @@ -259,8 +1529,10 @@ app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) = ORDER BY recorded_on DESC LIMIT 1 ) latest ON TRUE - ORDER BY birds.name ASC - `); + WHERE birds.workspace_id = $1 + ORDER BY birds.name ASC`, + [req.auth!.workspace.id], + ); res.json({ birds: result.rows.map(normalizeBird) }); } catch (error) { @@ -268,7 +1540,7 @@ app.get('/api/birds', async (_req: Request, res: Response, next: NextFunction) = } }); -app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { @@ -278,10 +1550,11 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) = try { const result = await pool.query( - `INSERT INTO birds (name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + `INSERT INTO birds (workspace_id, name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, workspace_id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, [ + req.auth!.workspace.id, parsed.data.name, parsed.data.tagId, parsed.data.species, @@ -289,13 +1562,15 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) = emptyToNull(parsed.data.gotchaDay), parsed.data.chartColor ?? '#cb3a35', emptyToNull(parsed.data.photoDataUrl), + parsed.data.notifyOnDob ?? false, + parsed.data.notifyOnGotchaDay ?? false, ], ); res.status(201).json({ bird: normalizeBird(result.rows[0]) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { - res.status(409).json({ error: 'That tag ID is already in use.' }); + res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' }); return; } @@ -303,7 +1578,7 @@ app.post('/api/birds', async (req: Request, res: Response, next: NextFunction) = } }); -app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => { +app.put('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { @@ -320,9 +1595,12 @@ app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunc date_of_birth = $5, gotcha_day = $6, chart_color = $7, - photo_data_url = $8 + photo_data_url = $8, + notify_on_dob = $9, + notify_on_gotcha_day = $10 WHERE id = $1 - RETURNING id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, created_at, + AND workspace_id = $11 + RETURNING id, workspace_id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -346,6 +1624,9 @@ app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunc emptyToNull(parsed.data.gotchaDay), parsed.data.chartColor ?? '#cb3a35', emptyToNull(parsed.data.photoDataUrl), + parsed.data.notifyOnDob ?? false, + parsed.data.notifyOnGotchaDay ?? false, + req.auth!.workspace.id, ], ); @@ -357,7 +1638,7 @@ app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunc res.json({ bird: normalizeBird(result.rows[0]) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { - res.status(409).json({ error: 'That tag ID is already in use.' }); + res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' }); return; } @@ -365,9 +1646,15 @@ app.put('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunc } }); -app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { try { - const result = await pool.query<{ id: string }>('DELETE FROM birds WHERE id = $1 RETURNING id', [req.params.birdId]); + const result = await pool.query<{ id: string }>( + `DELETE FROM birds + WHERE id = $1 + AND workspace_id = $2 + RETURNING id`, + [req.params.birdId, req.auth!.workspace.id], + ); if (!result.rowCount) { res.status(404).json({ error: 'Bird not found.' }); @@ -380,16 +1667,22 @@ app.delete('/api/birds/:birdId', async (req: Request, res: Response, next: NextF } }); -app.get('/api/birds/:birdId/weights', async (req: Request, res: Response, next: NextFunction) => { +app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 365); const result = await pool.query( `SELECT id, bird_id, weight_grams, recorded_on::text, notes FROM weight_records WHERE bird_id = $1 + AND EXISTS ( + SELECT 1 + FROM birds + WHERE birds.id = weight_records.bird_id + AND birds.workspace_id = $3 + ) AND recorded_on >= CURRENT_DATE - (($2::int - 1) * INTERVAL '1 day') ORDER BY recorded_on ASC`, - [req.params.birdId, days], + [req.params.birdId, days, req.auth!.workspace.id], ); res.json({ weights: result.rows.map(normalizeWeight) }); @@ -398,7 +1691,7 @@ app.get('/api/birds/:birdId/weights', async (req: Request, res: Response, next: } }); -app.post('/api/birds/:birdId/weights', async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds/:birdId/weights', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = weightSchema.safeParse(req.body); if (!parsed.success) { @@ -407,9 +1700,9 @@ app.post('/api/birds/:birdId/weights', async (req: Request, res: Response, next: } try { - const birdLookup = await pool.query<{ id: string }>('SELECT id FROM birds WHERE id = $1', [req.params.birdId]); + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); - if (!birdLookup.rowCount) { + if (!bird) { res.status(404).json({ error: 'Bird not found.' }); return; } @@ -432,14 +1725,20 @@ app.post('/api/birds/:birdId/weights', async (req: Request, res: Response, next: } }); -app.get('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, next: NextFunction) => { +app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const result = await pool.query( `SELECT id, bird_id, visited_on::text, clinic_name, reason, notes FROM vet_visits WHERE bird_id = $1 + AND EXISTS ( + SELECT 1 + FROM birds + WHERE birds.id = vet_visits.bird_id + AND birds.workspace_id = $2 + ) ORDER BY visited_on DESC, created_at DESC`, - [req.params.birdId], + [req.params.birdId, req.auth!.workspace.id], ); res.json({ vetVisits: result.rows.map(normalizeVetVisit) }); @@ -448,7 +1747,7 @@ app.get('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, nex } }); -app.post('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = vetVisitSchema.safeParse(req.body); if (!parsed.success) { @@ -457,7 +1756,7 @@ app.post('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, ne } try { - const bird = await getBirdById(req.params.birdId); + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); if (!bird) { res.status(404).json({ error: 'Bird not found.' }); @@ -468,13 +1767,7 @@ app.post('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, ne `INSERT INTO vet_visits (bird_id, visited_on, clinic_name, reason, notes) VALUES ($1, $2, $3, $4, $5) RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`, - [ - req.params.birdId, - parsed.data.visitedOn, - parsed.data.clinicName, - parsed.data.reason, - emptyToNull(parsed.data.notes), - ], + [req.params.birdId, parsed.data.visitedOn, parsed.data.clinicName, parsed.data.reason, emptyToNull(parsed.data.notes)], ); res.status(201).json({ vetVisit: normalizeVetVisit(result.rows[0]) }); @@ -485,7 +1778,7 @@ app.post('/api/birds/:birdId/vet-visits', async (req: Request, res: Response, ne app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error(error); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); }); const start = async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5edf0ba..0a80dab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; +type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus'; +type WorkspaceType = 'standard' | 'rescue'; +type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; + type Bird = { id: string; + workspaceId?: number; name: string; tagId: string; species: string; @@ -9,6 +14,8 @@ type Bird = { gotchaDay: string | null; chartColor: string; photoDataUrl: string | null; + notifyOnDob: boolean; + notifyOnGotchaDay: boolean; createdAt: string; latestWeightGrams: number | null; latestRecordedOn: string | null; @@ -31,6 +38,54 @@ type VetVisit = { notes: string | null; }; +type Workspace = { + id: number; + name: string; + workspaceType: WorkspaceType; + billingEmail: string | null; + billingPlan: BillingPlan; + createdAt: string; + updatedAt: string; +}; + +type WorkspaceMember = { + id: string; + workspaceId: number; + userId?: string | null; + inviteEmail?: string; + name: string; + email?: string; + role: WorkspaceRole; + acceptedAt?: string | null; + createdAt: string; +}; + +type WorkspaceSummary = { + membership: WorkspaceMember; + workspace: Workspace; +}; + +type AuthProvider = { + providerKey: 'google' | 'microsoft' | 'apple'; + displayName: string; + enabled: boolean; +}; + +type AuthUser = { + id: string; + email: string; + name: string; + createdAt: string; +}; + +type AuthSessionPayload = { + user: AuthUser; + activeWorkspace: Workspace; + activeMembership: WorkspaceMember; + workspaces: WorkspaceSummary[]; + providers: AuthProvider[]; +}; + type BirdFormState = { name: string; tagId: string; @@ -39,11 +94,62 @@ type BirdFormState = { gotchaDay: string; chartColor: string; photoDataUrl: string; + notifyOnDob: boolean; + notifyOnGotchaDay: boolean; +}; + +type WorkspaceFormState = { + name: string; + workspaceType: WorkspaceType; + billingEmail: string; + billingPlan: 'household_basic' | 'household_plus'; +}; + +type WorkspaceMemberFormState = { + name: string; + email: string; + role: WorkspaceRole; +}; + +type WorkspaceCreateFormState = { + name: string; + workspaceType: WorkspaceType; + billingEmail: string; + billingPlan: 'household_basic' | 'household_plus'; +}; + +type AuthFormState = { + name: string; + email: string; +}; + +type AuthNotice = { + message: string; + previewUrl?: string | null; +}; + +type PhotoCropState = { + sourceDataUrl: string; + fileName: string; + naturalWidth: number; + naturalHeight: number; + zoom: number; + offsetX: number; + offsetY: number; +}; + +type PhotoDragState = { + pointerId: number; + startX: number; + startY: number; + startOffsetX: number; + startOffsetY: number; }; type AppPage = 'overview' | 'flock' | 'settings'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; +const sessionTokenStorageKey = 'flockpal_auth_token'; const emptyBirdForm: BirdFormState = { name: '', tagId: '', @@ -52,6 +158,84 @@ const emptyBirdForm: BirdFormState = { gotchaDay: '', chartColor: '#cb3a35', photoDataUrl: '', + notifyOnDob: false, + notifyOnGotchaDay: false, +}; + +const emptyWorkspaceForm: WorkspaceFormState = { + name: 'My Flock', + workspaceType: 'standard', + billingEmail: '', + billingPlan: 'household_basic', +}; + +const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { + name: '', + email: '', + role: 'staff', +}; + +const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { + name: '', + workspaceType: 'standard', + billingEmail: '', + billingPlan: 'household_basic', +}; + +const emptyAuthForm: AuthFormState = { + name: '', + email: '', +}; + +const defaultAuthProviders: AuthProvider[] = [ + { providerKey: 'google', displayName: 'Google', enabled: false }, + { providerKey: 'microsoft', displayName: 'Microsoft', enabled: false }, + { providerKey: 'apple', displayName: 'Apple', enabled: false }, +]; + +const ProviderIcon = ({ providerKey }: { providerKey: AuthProvider['providerKey'] }) => { + if (providerKey === 'google') { + return ( + + ); + } + + if (providerKey === 'microsoft') { + return ( + + ); + } + + return ( + + ); }; const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name)); @@ -64,6 +248,8 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ gotchaDay: bird.gotchaDay ?? '', chartColor: bird.chartColor, photoDataUrl: bird.photoDataUrl ?? '', + notifyOnDob: bird.notifyOnDob, + notifyOnGotchaDay: bird.notifyOnGotchaDay, }); const formatDate = (value: string | null) => { @@ -94,6 +280,10 @@ const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const OVERVIEW_WIDTH = 520; const OVERVIEW_HEIGHT = 220; const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 }; +const PHOTO_MAX_BYTES = 900_000; +const PHOTO_EXPORT_SIZES = [720, 600, 480]; +const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66]; +const PHOTO_PREVIEW_SIZE = 112; const readJsonSafely = async (response: Response): Promise => { const contentType = response.headers.get('content-type') ?? ''; @@ -130,6 +320,160 @@ const readErrorMessage = async (response: Response, fallback: string) => { return trimmed; }; +const createApiHeaders = (token?: string, headers?: HeadersInit) => { + const nextHeaders = new Headers(headers); + + if (token) { + nextHeaders.set('Authorization', `Bearer ${token}`); + } + + return nextHeaders; +}; + +const apiFetch = (path: string, token?: string, init?: RequestInit) => + fetch(`${apiBaseUrl}${path}`, { + ...init, + headers: createApiHeaders(token, init?.headers), + }); + +const persistSessionToken = (token: string) => { + window.localStorage.setItem(sessionTokenStorageKey, token); +}; + +const clearSessionToken = () => { + window.localStorage.removeItem(sessionTokenStorageKey); +}; + +const readStoredSessionToken = () => window.localStorage.getItem(sessionTokenStorageKey) ?? ''; + +const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => { + const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`); + url.searchParams.set('redirectTo', window.location.href); + return url.toString(); +}; + +const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is 'household_basic' | 'household_plus' => + billingPlan === 'household_basic' || billingPlan === 'household_plus'; + +const readFileAsDataUrl = async (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(new Error('Unable to read that photo.')); + reader.readAsDataURL(file); + }); + +const loadImageElement = async (sourceDataUrl: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Unable to prepare that photo for cropping.')); + image.src = sourceDataUrl; + }); + +const blobToDataUrl = async (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(new Error('Unable to finalize that cropped photo.')); + reader.readAsDataURL(blob); + }); + +const canvasToBlob = async (canvas: HTMLCanvasElement, quality: number) => + new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Unable to export that cropped photo.')); + return; + } + + resolve(blob); + }, + 'image/webp', + quality, + ); + }); + +const exportCroppedPhoto = async (cropState: PhotoCropState) => { + const image = await loadImageElement(cropState.sourceDataUrl); + const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight); + const cropSize = shortestSide / cropState.zoom; + const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2); + const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2); + const sourceX = Math.min( + cropState.naturalWidth - cropSize, + Math.max(0, (cropState.naturalWidth - cropSize) / 2 + (cropState.offsetX / 100) * maxOffsetX), + ); + const sourceY = Math.min( + cropState.naturalHeight - cropSize, + Math.max(0, (cropState.naturalHeight - cropSize) / 2 + (cropState.offsetY / 100) * maxOffsetY), + ); + + let bestBlob: Blob | null = null; + + for (const size of PHOTO_EXPORT_SIZES) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Unable to prepare the crop tool in this browser.'); + } + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + context.drawImage(image, sourceX, sourceY, cropSize, cropSize, 0, 0, size, size); + + for (const quality of PHOTO_EXPORT_QUALITIES) { + const blob = await canvasToBlob(canvas, quality); + + if (!bestBlob || blob.size < bestBlob.size) { + bestBlob = blob; + } + + if (blob.size <= PHOTO_MAX_BYTES) { + return blobToDataUrl(blob); + } + } + } + + if (!bestBlob) { + throw new Error('Unable to export that cropped photo.'); + } + + if (bestBlob.size > PHOTO_MAX_BYTES) { + throw new Error('That photo is still too large after cropping. Try zooming in a little more.'); + } + + return blobToDataUrl(bestBlob); +}; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const getPhotoCropMetrics = (cropState: PhotoCropState, frameSize = PHOTO_PREVIEW_SIZE) => { + const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight); + const cropSize = shortestSide / cropState.zoom; + const scale = frameSize / cropSize; + const displayWidth = cropState.naturalWidth * scale; + const displayHeight = cropState.naturalHeight * scale; + const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2); + const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2); + const left = (frameSize - displayWidth) / 2 - (cropState.offsetX / 100) * maxOffsetX * scale; + const top = (frameSize - displayHeight) / 2 - (cropState.offsetY / 100) * maxOffsetY * scale; + + return { + left, + top, + displayWidth, + displayHeight, + scale, + maxOffsetX, + maxOffsetY, + }; +}; + const chartPath = (points: WeightRecord[], width = 520, height = 180) => { if (!points.length) { return ''; @@ -194,6 +538,16 @@ const toOverviewPath = (points: { x: number; y: number }[]) => function App() { const [activePage, setActivePage] = useState('overview'); + const [authToken, setAuthToken] = useState(''); + const [authSession, setAuthSession] = useState(null); + const [authProviders, setAuthProviders] = useState([]); + const [authForm, setAuthForm] = useState(emptyAuthForm); + const [authNotice, setAuthNotice] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [authSubmitting, setAuthSubmitting] = useState(false); + const [workspace, setWorkspace] = useState(null); + const [activeMembership, setActiveMembership] = useState(null); + const [workspaceMembers, setWorkspaceMembers] = useState([]); const [birds, setBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [editingBirdId, setEditingBirdId] = useState(''); @@ -202,9 +556,19 @@ function App() { const [allBirdWeights, setAllBirdWeights] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [workspaceForm, setWorkspaceForm] = useState(emptyWorkspaceForm); + const [workspaceMemberForm, setWorkspaceMemberForm] = useState(emptyWorkspaceMemberForm); + const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); const [birdPhotoName, setBirdPhotoName] = useState(''); + const [photoCrop, setPhotoCrop] = useState(null); + const [photoDrag, setPhotoDrag] = useState(null); + const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false); const [savingBird, setSavingBird] = useState(false); + const [savingWorkspace, setSavingWorkspace] = useState(false); + const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); + const [creatingWorkspace, setCreatingWorkspace] = useState(false); + const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [weightForm, setWeightForm] = useState({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), @@ -224,9 +588,10 @@ function App() { notes: '', }); const [deletingBird, setDeletingBird] = useState(false); + const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const selectedBird = useMemo( - () => birds.find((bird) => bird.id === selectedBirdId) ?? birds[0] ?? null, + () => birds.find((bird) => bird.id === selectedBirdId) ?? null, [birds, selectedBirdId], ); const editingBird = useMemo( @@ -234,11 +599,6 @@ function App() { [birds, editingBirdId], ); - const totalWeightEntries = useMemo( - () => Object.values(allBirdWeights).reduce((total, entries) => total + entries.length, 0), - [allBirdWeights], - ); - const birdsWithRecentWeights = useMemo( () => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).length > 0), [allBirdWeights, birds], @@ -311,21 +671,147 @@ function App() { }; }, [allBirdWeights, birds]); + const applySession = (session: AuthSessionPayload, token: string) => { + setAuthToken(token); + setAuthSession(session); + setAuthProviders(session.providers); + setAuthNotice(null); + setWorkspace(session.activeWorkspace); + setActiveMembership({ + ...session.activeMembership, + email: session.activeMembership.inviteEmail, + }); + setWorkspaceForm({ + name: session.activeWorkspace.name, + workspaceType: session.activeWorkspace.workspaceType, + billingEmail: session.activeWorkspace.billingEmail ?? '', + billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic', + }); + setWorkspaceCreateForm((current) => ({ + ...current, + billingEmail: current.billingEmail || session.user.email, + })); + }; + + const clearAppSession = () => { + clearSessionToken(); + setAuthToken(''); + setAuthSession(null); + setWorkspace(null); + setActiveMembership(null); + setWorkspaceMembers([]); + setBirds([]); + setWeights([]); + setVetVisits([]); + setAllBirdWeights({}); + setSelectedBirdId(''); + setEditingBirdId(''); + setWorkspaceForm(emptyWorkspaceForm); + setWorkspaceCreateForm(emptyWorkspaceCreateForm); + setAuthNotice(null); + }; + useEffect(() => { + const loadProviders = async () => { + try { + const response = await apiFetch('/auth/providers'); + + if (!response.ok) { + setAuthProviders(defaultAuthProviders); + return; + } + + const data = (await readJsonSafely<{ providers?: AuthProvider[] }>(response)) ?? {}; + const mergedProviders = defaultAuthProviders.map((defaultProvider) => { + const matchingProvider = (data.providers ?? []).find((provider) => provider.providerKey === defaultProvider.providerKey); + return matchingProvider ?? defaultProvider; + }); + setAuthProviders(mergedProviders); + } catch { + setAuthProviders(defaultAuthProviders); + } + }; + + const bootstrapSession = async () => { + try { + setAuthLoading(true); + await loadProviders(); + + const url = new URL(window.location.href); + const callbackToken = url.searchParams.get('auth_token') ?? ''; + const token = callbackToken || readStoredSessionToken(); + + if (callbackToken) { + persistSessionToken(callbackToken); + url.searchParams.delete('auth_token'); + window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); + } + + if (!token) { + return; + } + + const response = await apiFetch('/auth/session', token); + + if (!response.ok) { + clearAppSession(); + return; + } + + const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; + + if (data.session && (data.token || token)) { + persistSessionToken(data.token || token); + applySession(data.session, data.token || token); + setError(''); + } + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Unable to load your session.'); + } finally { + setAuthLoading(false); + } + }; + + void bootstrapSession(); + }, []); + + useEffect(() => { + if (!authToken || !workspace?.id) { + setLoading(false); + return; + } + const loadBirds = async () => { try { setLoading(true); - const response = await fetch(`${apiBaseUrl}/birds`); + const [birdsResponse, membersResponse] = await Promise.all([apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken)]); - if (!response.ok) { - throw new Error(await readErrorMessage(response, 'Unable to load flock members.')); + if (!birdsResponse.ok) { + if (birdsResponse.status === 401) { + clearAppSession(); + return; + } + + throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.')); } - const data = (await readJsonSafely<{ birds?: Bird[] }>(response)) ?? {}; + const data = (await readJsonSafely<{ birds?: Bird[] }>(birdsResponse)) ?? {}; const nextBirds = data.birds ?? []; setBirds(nextBirds); - setSelectedBirdId((current) => current || nextBirds[0]?.id || ''); + setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : '')); + + if (membersResponse.ok) { + const membersData = (await readJsonSafely<{ members?: WorkspaceMember[] }>(membersResponse)) ?? {}; + setWorkspaceMembers( + (membersData.members ?? []).map((member) => ({ + ...member, + email: member.inviteEmail, + })), + ); + } else { + setWorkspaceMembers([]); + } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { @@ -334,7 +820,7 @@ function App() { }; void loadBirds(); - }, []); + }, [authToken, workspace?.id]); useEffect(() => { if (!selectedBird?.id) { @@ -346,8 +832,8 @@ function App() { const loadBirdDetail = async () => { try { const [weightsResponse, visitsResponse] = await Promise.all([ - fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights?days=90`), - fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`), + apiFetch(`/birds/${selectedBird.id}/weights?days=90`, authToken), + apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken), ]); if (!weightsResponse.ok || !visitsResponse.ok) { @@ -365,10 +851,10 @@ function App() { }; void loadBirdDetail(); - }, [selectedBird?.id]); + }, [authToken, selectedBird?.id]); useEffect(() => { - if (!birds.length) { + if (!authToken || !birds.length) { setAllBirdWeights({}); return; } @@ -377,7 +863,7 @@ function App() { try { const responses = await Promise.all( birds.map(async (bird) => { - const response = await fetch(`${apiBaseUrl}/birds/${bird.id}/weights?days=30`); + const response = await apiFetch(`/birds/${bird.id}/weights?days=30`, authToken); if (!response.ok) { throw new Error(await readErrorMessage(response, 'Unable to load overview weights.')); @@ -395,7 +881,7 @@ function App() { }; void loadAllBirdWeights(); - }, [birds]); + }, [authToken, birds]); useEffect(() => { if (!editingBirdId) { @@ -406,26 +892,173 @@ function App() { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); + setPhotoCrop(null); + setPhotoDrag(null); return; } setBirdForm(toBirdForm(editingBird)); setBirdPhotoName(''); + setPhotoCrop(null); + setPhotoDrag(null); }, [editingBird, editingBirdId]); const startCreateBird = () => { setEditingBirdId(''); setBirdForm(emptyBirdForm); setBirdPhotoName(''); + setPhotoCrop(null); + setPhotoDrag(null); setError(''); setActivePage('settings'); }; + const handleAuthSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setAuthNotice(null); + setAuthSubmitting(true); + + try { + const response = await apiFetch('/auth/magic-link/request', undefined, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: authForm.name.trim(), + email: authForm.email, + redirectTo: window.location.href, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to send your sign-in link.')); + } + + const data = (await readJsonSafely<{ message?: string; previewUrl?: string | null }>(response)) ?? {}; + setAuthNotice({ + message: data.message ?? 'Check your email for a secure sign-in link.', + previewUrl: data.previewUrl, + }); + setAuthForm(emptyAuthForm); + } catch (authError) { + setError(authError instanceof Error ? authError.message : 'Unable to send your sign-in link.'); + } finally { + setAuthSubmitting(false); + } + }; + + const handleLogout = async () => { + setError(''); + + try { + if (authToken) { + await apiFetch('/auth/logout', authToken, { method: 'POST' }); + } + } catch { + // Best-effort logout. + } finally { + clearAppSession(); + setAuthForm(emptyAuthForm); + } + }; + + const handleWorkspaceSwitch = async (workspaceId: number) => { + if (!authToken || workspaceId === workspace?.id) { + return; + } + + setError(''); + setSwitchingWorkspaceId(workspaceId); + + try { + const response = await apiFetch('/auth/switch-workspace', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to switch workspaces.')); + } + + const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; + + if (!data.session) { + throw new Error('Unable to switch workspaces.'); + } + + const nextToken = data.token || authToken; + persistSessionToken(nextToken); + applySession(data.session, nextToken); + setSelectedBirdId(''); + setEditingBirdId(''); + setWeights([]); + setVetVisits([]); + setActivePage('overview'); + } catch (switchError) { + setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.'); + } finally { + setSwitchingWorkspaceId(null); + } + }; + + const handleCreateWorkspace = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!authToken) { + return; + } + + setError(''); + setCreatingWorkspace(true); + + try { + const response = await apiFetch('/workspaces', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: workspaceCreateForm.name, + workspaceType: workspaceCreateForm.workspaceType, + billingEmail: workspaceCreateForm.billingEmail, + billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to create workspace.')); + } + + const workspaceResponse = await apiFetch('/auth/session', authToken); + if (!workspaceResponse.ok) { + throw new Error(await readErrorMessage(workspaceResponse, 'Workspace was created but the session could not be refreshed.')); + } + + const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {}; + if (!data.session) { + throw new Error('Unable to refresh your workspace list.'); + } + + const nextToken = data.token || authToken; + persistSessionToken(nextToken); + applySession(data.session, nextToken); + setWorkspaceCreateForm({ + ...emptyWorkspaceCreateForm, + billingEmail: data.session.user.email, + }); + } catch (workspaceError) { + setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.'); + } finally { + setCreatingWorkspace(false); + } + }; + const startEditBird = (bird: Bird) => { setSelectedBirdId(bird.id); setEditingBirdId(bird.id); setBirdForm(toBirdForm(bird)); setBirdPhotoName(''); + setPhotoCrop(null); + setPhotoDrag(null); setError(''); setActivePage('settings'); }; @@ -437,21 +1070,26 @@ function App() { return; } - if (file.size > 900_000) { - setError('Photo is too large. Please choose an image under 900 KB.'); + if (file.size > 12_000_000) { + setError('Photo is too large to process in the browser. Please choose one under 12 MB.'); event.target.value = ''; return; } try { - const photoDataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); - reader.onerror = () => reject(new Error('Unable to read that photo.')); - reader.readAsDataURL(file); - }); + const photoDataUrl = await readFileAsDataUrl(file); + const image = await loadImageElement(photoDataUrl); - setBirdForm((current) => ({ ...current, photoDataUrl })); + setPhotoCrop({ + sourceDataUrl: photoDataUrl, + fileName: file.name, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + zoom: 1, + offsetX: 0, + offsetY: 0, + }); + setPhotoDrag(null); setBirdPhotoName(file.name); setError(''); } catch (photoError) { @@ -461,9 +1099,76 @@ function App() { } }; + const handleApplyPhotoCrop = async () => { + if (!photoCrop) { + return; + } + + setApplyingPhotoCrop(true); + setError(''); + + try { + const photoDataUrl = await exportCroppedPhoto(photoCrop); + setBirdForm((current) => ({ ...current, photoDataUrl })); + setBirdPhotoName(photoCrop.fileName); + setPhotoCrop(null); + setPhotoDrag(null); + } catch (cropError) { + setError(cropError instanceof Error ? cropError.message : 'Unable to crop that photo.'); + } finally { + setApplyingPhotoCrop(false); + } + }; + + const handleCancelPhotoCrop = () => { + setPhotoCrop(null); + setPhotoDrag(null); + setError(''); + }; + const handleRemovePhoto = () => { setBirdForm((current) => ({ ...current, photoDataUrl: '' })); setBirdPhotoName(''); + setPhotoCrop(null); + setPhotoDrag(null); + }; + + const handlePhotoCropPointerDown = (event: React.PointerEvent) => { + if (!photoCrop) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setPhotoDrag({ + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startOffsetX: photoCrop.offsetX, + startOffsetY: photoCrop.offsetY, + }); + }; + + const handlePhotoCropPointerMove = (event: React.PointerEvent) => { + if (!photoCrop || !photoDrag || photoDrag.pointerId !== event.pointerId) { + return; + } + + const metrics = getPhotoCropMetrics(photoCrop); + const deltaX = event.clientX - photoDrag.startX; + const deltaY = event.clientY - photoDrag.startY; + const nextOffsetX = + metrics.maxOffsetX > 0 ? clamp(photoDrag.startOffsetX - (deltaX / (metrics.maxOffsetX * metrics.scale)) * 100, -100, 100) : 0; + const nextOffsetY = + metrics.maxOffsetY > 0 ? clamp(photoDrag.startOffsetY - (deltaY / (metrics.maxOffsetY * metrics.scale)) * 100, -100, 100) : 0; + + setPhotoCrop((current) => (current ? { ...current, offsetX: nextOffsetX, offsetY: nextOffsetY } : current)); + }; + + const handlePhotoCropPointerUp = (event: React.PointerEvent) => { + if (photoDrag?.pointerId === event.pointerId) { + event.currentTarget.releasePointerCapture(event.pointerId); + setPhotoDrag(null); + } }; const handleBirdSubmit = async (event: React.FormEvent) => { @@ -472,11 +1177,10 @@ function App() { setSavingBird(true); const isEditing = Boolean(editingBirdId); - const endpoint = isEditing ? `${apiBaseUrl}/birds/${editingBirdId}` : `${apiBaseUrl}/birds`; const method = isEditing ? 'PUT' : 'POST'; try { - const response = await fetch(endpoint, { + const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(birdForm), @@ -499,7 +1203,7 @@ function App() { return sortBirdsByName([...current, savedBird]); }); - setSelectedBirdId(savedBird.id); + setSelectedBirdId(isEditing ? savedBird.id : ''); setEditingBirdId(savedBird.id); setBirdForm(toBirdForm(savedBird)); setBirdPhotoName(''); @@ -521,7 +1225,7 @@ function App() { setError(''); try { - const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}/weights`, { + const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -577,7 +1281,7 @@ function App() { setError(''); try { - const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}/vet-visits`, { + const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(vetVisitForm), @@ -622,7 +1326,7 @@ function App() { setError(''); try { - const response = await fetch(`${apiBaseUrl}/birds/${selectedBird.id}`, { + const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, { method: 'DELETE', }); @@ -637,7 +1341,7 @@ function App() { delete next[selectedBird.id]; return next; }); - setSelectedBirdId(nextBirds[0]?.id ?? ''); + setSelectedBirdId(''); setWeights([]); setVetVisits([]); @@ -668,6 +1372,222 @@ function App() { }); }; + const handleWorkspaceSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setSavingWorkspace(true); + + try { + const response = await apiFetch('/workspace', authToken, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...workspaceForm, + billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to save workspace settings.')); + } + + const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {}; + + if (!data.workspace) { + throw new Error('Unable to save workspace settings.'); + } + + const savedWorkspace = data.workspace; + + setWorkspace(savedWorkspace); + setAuthSession((current) => + current + ? { + ...current, + activeWorkspace: savedWorkspace, + workspaces: current.workspaces.map((entry) => + entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry, + ), + } + : current, + ); + setWorkspaceForm({ + name: savedWorkspace.name, + workspaceType: savedWorkspace.workspaceType, + billingEmail: savedWorkspace.billingEmail ?? '', + billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', + }); + } catch (workspaceError) { + setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save workspace settings.'); + } finally { + setSavingWorkspace(false); + } + }; + + const handleWorkspaceMemberSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setSavingWorkspaceMember(true); + + try { + const response = await apiFetch('/workspace/members', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(workspaceMemberForm), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to add rescue team member.')); + } + + const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {}; + + if (!data.member) { + throw new Error('Unable to add rescue team member.'); + } + + const nextMember = { + ...data.member, + email: data.member.inviteEmail, + }; + + setWorkspaceMembers((current) => [...current, nextMember]); + setWorkspaceMemberForm(emptyWorkspaceMemberForm); + } catch (memberError) { + setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.'); + } finally { + setSavingWorkspaceMember(false); + } + }; + + const handleRemoveWorkspaceMember = async (memberId: string) => { + setError(''); + setRemovingWorkspaceMemberId(memberId); + + try { + const response = await apiFetch(`/workspace/members/${memberId}`, authToken, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to remove rescue team member.')); + } + + setWorkspaceMembers((current) => current.filter((member) => member.id !== memberId)); + } catch (memberError) { + setError(memberError instanceof Error ? memberError.message : 'Unable to remove rescue team member.'); + } finally { + setRemovingWorkspaceMemberId(''); + } + }; + + if (authLoading) { + return ( +
+
+
+

FlockPal

+

Loading your flock spaces...

+

Checking your sign-in and workspace access.

+
+
+
+ ); + } + + if (!authSession) { + return ( +
+
+
+

FlockPal

+

Bird care for households and rescues

+

+ Households can keep personal flocks in their own billed workspace, while rescue teams can share access to birds under a separate + rescue workspace at no charge. +

+
+
+ Standard household + Personal birds, optional collaborators, and household billing by workspace. +
+
+ Rescue workspace + Shared care for rescue birds with free access and team roles. +
+
+
+ +
+
+

Passwordless sign-in

+

Email link or provider

+

FlockPal no longer stores passwords. Use a magic link, Google, Microsoft, or Apple.

+
+ + {error ?

{error}

: null} + {authNotice ? ( +
+ {authNotice.message} + + {authNotice.previewUrl ? ( + Open the sign-in link + ) : ( + 'The link expires quickly and can only be used once.' + )} + +
+ ) : null} + +
+ + + +
+ + +
+
+
+ ); + } + if (loading) { return (
@@ -683,7 +1603,11 @@ function App() {
-
-
-

Dashboard

-

FlockPal dashboard

-
-
-
- {birds.length} - Flock members -
-
- {totalWeightEntries} - Weight records -
-
- {selectedBird ? formatWeight(selectedBird.latestWeightGrams) : 'Pending'} - Selected member -
-
-
+ {activePage !== 'settings' ? ( +
+
+

Dashboard

+

FlockPal dashboard

+
+
+
+ {birds.length} + Flock members +
+
+
+ ) : null} {error ?

{error}

: null} @@ -840,12 +1785,13 @@ function App() { ) : null} {activePage === 'flock' ? ( -
+
+ {selectedBird ? (

Flock member

-

{selectedBird ? selectedBird.name : 'Choose a flock member'}

+

{selectedBird.name}

+
+
+ +
- {selectedBird ? ( -
- - -
- ) : null}
- {selectedBird ? ( <>
{selectedBird.photoDataUrl ? ( @@ -1075,15 +2017,232 @@ function App() {
- ) : ( -

Add a flock member in Settings to start tracking individual health records.

- )}
+ ) : null}
) : null} {activePage === 'settings' ? (
+
+
+
+

Workspace

+

Workspace profile and billing

+
+
+

+ Each workspace carries its own billing and collaboration rules. That lets one person keep a personal household flock while also + participating in a rescue workspace without mixing billing or bird ownership. +

+
+ + + {workspaceForm.workspaceType === 'standard' ? ( + + ) : ( +
+ Rescue Free + Rescue workspaces stay free while still supporting shared team access. +
+ )} + + +
+
+ +
+
+
+

Collaborators

+

Shared workspace access

+
+
+

+ Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support + co-caregivers without changing who owns the billing. +

+
+ + + + +
+ +
+ {workspaceMembers.length ? ( + workspaceMembers.map((member) => ( +
+ {member.name} + + {member.role} • {member.email || member.inviteEmail} + + {member.acceptedAt ? 'Active access' : 'Invitation pending'} + +
+ )) + ) : ( +
+ No collaborators yet + Add the people who should be able to help care for birds in this workspace. +
+ )} +
+
+ +
+
+
+

New workspace

+

Add another flock space

+
+
+

+ This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate for + access and billing. +

+
+ + + {workspaceCreateForm.workspaceType === 'standard' ? ( + + ) : ( +
+ Rescue Free + No billing is applied to rescue workspaces. +
+ )} + + +
+
+
@@ -1144,6 +2303,24 @@ function App() { onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })} /> + +