From 37c82653207abb94d394735ada821a687e22c3eb Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Tue, 14 Apr 2026 22:41:17 -0400 Subject: [PATCH] added full api --- README.md | 2 + backend/package.json | 1 + backend/src/app.ts | 1271 ++++------------- backend/src/db/client.ts | 31 + backend/src/db/schema.ts | 226 +++ .../src/repositories/authRepository.test.ts | 93 ++ backend/src/repositories/authRepository.ts | 478 +++++++ .../src/repositories/birdRepository.test.ts | 69 + backend/src/repositories/birdRepository.ts | 222 +++ .../integrationTokenRepository.ts | 58 + .../repositories/workspaceRepository.test.ts | 63 + .../src/repositories/workspaceRepository.ts | 251 ++++ backend/src/test/mockDb.ts | 52 + backend/src/types.ts | 128 ++ docs/API_REFERENCE.md | 929 ++++++++++++ frontend/src/App.tsx | 240 +++- frontend/src/index.css | 10 + 17 files changed, 3146 insertions(+), 978 deletions(-) create mode 100644 backend/src/db/client.ts create mode 100644 backend/src/db/schema.ts create mode 100644 backend/src/repositories/authRepository.test.ts create mode 100644 backend/src/repositories/authRepository.ts create mode 100644 backend/src/repositories/birdRepository.test.ts create mode 100644 backend/src/repositories/birdRepository.ts create mode 100644 backend/src/repositories/integrationTokenRepository.ts create mode 100644 backend/src/repositories/workspaceRepository.test.ts create mode 100644 backend/src/repositories/workspaceRepository.ts create mode 100644 backend/src/test/mockDb.ts create mode 100644 backend/src/types.ts create mode 100644 docs/API_REFERENCE.md diff --git a/README.md b/README.md index d162b95..97c9490 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ docker compose up --build 3. Open `http://localhost:3000`. 4. The API health check is available at `http://localhost:5000/api/health`. +Full API documentation is available in [docs/API_REFERENCE.md](docs/API_REFERENCE.md). + The default `docker-compose.yml` is development-only. It mounts source files, installs dev dependencies, and runs the backend and frontend in watch mode. ## Production diff --git a/backend/package.json b/backend/package.json index 89aa4ac..874b4a0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "tsx watch src/app.ts", "build": "tsc", + "test": "tsx --test src/**/*.test.ts", "start": "node dist/app.js" }, "dependencies": { diff --git a/backend/src/app.ts b/backend/src/app.ts index a1e464b..0bf74ca 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,123 +6,68 @@ 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'; +import { ensureSchema } from './db/schema.js'; +import { + consumeMagicLinkToken, + consumeOAuthState, + createAuthSession as createAuthSessionRecord, + createMagicLinkToken, + createOAuthState, + createUser, + deleteAuthSession, + deleteExpiredMagicLinkTokens, + findUserByEmail, + findUserByProviderAccount, + linkAuthAccount, + resolveAuth as resolveSessionAuth, + resolveIntegrationTokenAuth, + updateSessionWorkspace, + updateUserName, +} from './repositories/authRepository.js'; +import { + createBird, + createVetVisitForBird, + createWeightForBird, + deleteBird, + getBirdById, + listBirds, + listVetVisitsForBird, + listWeightsForBird, + updateBird, +} from './repositories/birdRepository.js'; +import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; +import { + claimWorkspaceInvites, + createWorkspace, + deleteWorkspaceMember, + ensurePersonalWorkspaceForUser, + getMembershipForUser, + getNextWorkspaceId, + getWorkspaceById, + listMembershipsForUser, + listWorkspaceMembers, + updateWorkspace, + upsertWorkspaceMember, +} from './repositories/workspaceRepository.js'; +import type { + AuthContext, + BillingPlan, + BirdRow, + IntegrationTokenRow, + ProviderKey, + UserRow, + VetVisitRow, + WeightRow, + WorkspaceMemberRow, + WorkspaceRole, + WorkspaceRow, + WorkspaceType, +} from './types.js'; + dotenv.config(); -type WorkspaceType = 'standard' | 'rescue'; -type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; -type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; -type ProviderKey = 'google' | 'microsoft' | 'apple'; - -type UserRow = { - id: string; - email: string; - password_hash: string | null; - name: string; - created_at: string; -}; - -type WorkspaceRow = { - id: number; - name: string; - workspace_type: WorkspaceType; - billing_email: string | null; - billing_plan: BillingPlan; - created_at: string; - updated_at: string; -}; - -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; -}; - -type AuthSessionRow = { - id: string; - user_id: string; - active_workspace_id: number; - token_hash: string; - expires_at: string; - created_at: string; -}; - -type AuthAccountRow = { - id: string; - user_id: string; - provider_key: ProviderKey; - provider_subject: string; - provider_email: string | null; - created_at: string; -}; - -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; - date_of_birth: string | null; - 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; -}; - -type WeightRow = { - id: string; - bird_id: string; - weight_grams: string; - recorded_on: string; - notes: string | null; -}; - -type VetVisitRow = { - id: string; - bird_id: string; - visited_on: string; - clinic_name: string; - reason: string; - notes: string | null; -}; - -type AuthContext = { - user: UserRow; - session: AuthSessionRow; - workspace: WorkspaceRow; - membership: WorkspaceMemberRow; - token: string; -}; - declare global { namespace Express { interface Request { @@ -136,14 +81,6 @@ 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']; @@ -178,6 +115,7 @@ const switchWorkspaceSchema = z.object({ const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']); +const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const workspaceSchema = z.object({ name: z.string().trim().min(1).max(160), @@ -230,6 +168,12 @@ const vetVisitSchema = z.object({ notes: z.string().trim().max(1000).optional().or(z.literal('')), }); +const integrationTokenCreateSchema = z.object({ + name: z.string().trim().min(1).max(160), + scope: integrationTokenScopeSchema.default('read_write'), + expiresInDays: z.coerce.number().int().min(1).max(3650).optional(), +}); + const emptyToNull = (value?: string) => { const trimmed = value?.trim() ?? ''; return trimmed ? trimmed : null; @@ -238,6 +182,7 @@ const emptyToNull = (value?: string) => { const normalizeEmail = (value: string) => value.trim().toLowerCase(); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const createSessionToken = () => crypto.randomBytes(32).toString('hex'); +const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`; const createRandomId = () => crypto.randomUUID(); const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url'); const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url'); @@ -346,22 +291,18 @@ const normalizeVetVisit = (row: VetVisitRow) => ({ 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 normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ + id: row.id, + userId: row.user_id, + workspaceId: row.workspace_id, + name: row.name, + tokenPrefix: row.token_prefix, + scope: row.scope, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + createdAt: row.created_at, +}); const oauthProviders = { google: { @@ -421,259 +362,8 @@ app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: false })); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); -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 - ); - - ALTER TABLE workspace_members - ADD COLUMN IF NOT EXISTS email VARCHAR(255), - ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, - ADD COLUMN IF NOT EXISTS invite_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ; - - DO $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'workspace_members' - AND column_name = 'email' - ) THEN - UPDATE workspace_members - SET invite_email = COALESCE(invite_email, email) - WHERE invite_email IS NULL; - END IF; - END $$; - - UPDATE workspace_members - SET invite_email = '' - WHERE invite_email IS NULL; - - UPDATE workspace_members - SET email = invite_email - WHERE email IS NULL; - - ALTER TABLE workspace_members - ALTER COLUMN invite_email SET NOT NULL; - - 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, - 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 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(), - bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, - weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0), - recorded_on DATE NOT NULL, - notes VARCHAR(280), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (bird_id, recorded_on) - ); - - CREATE TABLE IF NOT EXISTS vet_visits ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, - visited_on DATE NOT NULL, - clinic_name VARCHAR(160) NOT NULL, - reason VARCHAR(160) NOT NULL, - notes VARCHAR(1000), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on - ON weight_records (bird_id, recorded_on DESC); - - CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on - ON vet_visits (bird_id, visited_on DESC); - `); -}; - -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, COALESCE(invite_email, email) AS 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, - COALESCE(workspace_members.invite_email, workspace_members.email) AS 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) => ({ +const normalizeWorkspaceMembershipList = async (userId: string) => + (await listMembershipsForUser(userId)).map((row) => ({ membership: normalizeWorkspaceMember(row), workspace: normalizeWorkspace({ id: row.workspace_id, @@ -685,80 +375,6 @@ const listMembershipsForUser = async (userId: string) => { 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, email, invite_email, name, role, accepted_at) - VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP) - ON CONFLICT (workspace_id, invite_email) DO UPDATE - SET user_id = EXCLUDED.user_id, - email = EXCLUDED.email, - 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(COALESCE(invite_email, email)) = LOWER($2) - AND user_id IS NULL`, - [user.id, user.email], - ); -}; const createAuthSession = async (userId: string, activeWorkspaceId: number) => { const token = createSessionToken(); @@ -766,34 +382,22 @@ const createAuthSession = async (userId: string, activeWorkspaceId: number) => { 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()], - ); + await createAuthSessionRecord(userId, activeWorkspaceId, tokenHash, expiresAt.toISOString()); - return { - token, - session: result.rows[0], - }; + return { token }; }; -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 buildSessionPayload = async (auth: AuthContext) => ({ + user: normalizeUser(auth.user), + activeWorkspace: normalizeWorkspace(auth.workspace), + activeMembership: normalizeWorkspaceMember(auth.membership), + workspaces: await normalizeWorkspaceMembershipList(auth.user.id), + providers: Object.values(oauthProviders).map((provider) => ({ + providerKey: provider.providerKey, + displayName: provider.displayName, + enabled: Boolean(provider.clientId && provider.clientSecret), + })), +}); const sendMagicLink = async ({ email, @@ -847,20 +451,13 @@ const issueMagicLinkInvite = async ({ name: string | null; redirectTo?: string; }) => { - await pool.query( - `DELETE FROM magic_link_tokens - WHERE expires_at <= CURRENT_TIMESTAMP`, - ); + await deleteExpiredMagicLinkTokens(); 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], - ); + await createMagicLinkToken(email, name, tokenHash, redirectTo, expiresAt); const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`); verifyUrl.searchParams.set('token', rawToken); @@ -881,131 +478,18 @@ const readBearerToken = (authorizationHeader?: string) => { return scheme?.toLowerCase() === 'bearer' && token ? token.trim() : ''; }; -const resolveAuth = async (token: string) => { +const resolveAnyAuth = 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, - COALESCE(workspace_members.invite_email, workspace_members.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; + return (await resolveSessionAuth(hashToken(token), token)) ?? resolveIntegrationTokenAuth(hashToken(token), token); }; const requireAuth = async (req: Request, res: Response, next: NextFunction) => { try { const token = readBearerToken(req.headers.authorization); - const auth = await resolveAuth(token); + const auth = await resolveAnyAuth(token); if (!auth) { res.status(401).json({ error: 'Authentication required.' }); @@ -1019,6 +503,29 @@ const requireAuth = async (req: Request, res: Response, next: NextFunction) => { } }; +const requireSessionAuth = (req: Request, res: Response, next: NextFunction) => { + if (!req.auth) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + + if (req.auth.authType !== 'session') { + res.status(403).json({ error: 'This endpoint requires a browser session instead of an integration token.' }); + return; + } + + next(); +}; + +const requireWriteAccess = (req: Request, res: Response, next: NextFunction) => { + if (req.auth?.authType === 'integration_token' && req.auth.integrationToken?.scope !== 'read_write') { + res.status(403).json({ error: 'That integration token is read-only.' }); + return; + } + + next(); +}; + const requireWorkspaceRole = (allowedRoles: WorkspaceRole[]) => (req: Request, res: Response, next: NextFunction) => { if (!req.auth) { res.status(401).json({ error: 'Authentication required.' }); @@ -1033,26 +540,6 @@ const requireWorkspaceRole = (allowedRoles: WorkspaceRole[]) => (req: Request, r next(); }; -const getBirdById = async (birdId: string, workspaceId: number) => { - const result = await pool.query( - `SELECT - ${birdSelectFields} - FROM birds - LEFT JOIN LATERAL ( - SELECT weight_grams, recorded_on - FROM weight_records - WHERE weight_records.bird_id = birds.id - ORDER BY recorded_on DESC - LIMIT 1 - ) latest ON TRUE - WHERE birds.id = $1 - AND birds.workspace_id = $2`, - [birdId, workspaceId], - ); - - return result.rows[0] ?? null; -}; - app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true }); }); @@ -1105,7 +592,7 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex } }); -app.post('/api/transfers/draft', requireAuth, async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/transfers/draft', requireAuth, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { const parsed = transferDraftSchema.safeParse(req.body); if (!parsed.success) { @@ -1114,38 +601,20 @@ app.post('/api/transfers/draft', requireAuth, async (req: Request, res: Response } try { - const birdResult = await pool.query( - `SELECT ${birdSelectFields} - FROM birds - LEFT JOIN LATERAL ( - SELECT weight_grams, recorded_on - FROM weight_records - WHERE weight_records.bird_id = birds.id - ORDER BY recorded_on DESC - LIMIT 1 - ) latest ON TRUE - WHERE birds.id = $1 - AND birds.workspace_id = $2`, - [parsed.data.birdId, req.auth!.workspace.id], - ); + const bird = await getBirdById(parsed.data.birdId, req.auth!.workspace.id); - if (!birdResult.rowCount) { + if (!bird) { res.status(404).json({ error: 'That bird could not be found in this workspace.' }); return; } const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail); - const existingUser = await pool.query( - `SELECT id, email, password_hash, name, created_at - FROM users - WHERE email = $1`, - [destinationOwnerEmail], - ); + const existingUser = await findUserByEmail(destinationOwnerEmail); let invitePreviewUrl: string | null = null; let inviteDelivery: 'email' | 'preview' | null = null; - if (!existingUser.rowCount) { + if (!existingUser) { const delivery = await issueMagicLinkInvite({ email: destinationOwnerEmail, name: null, @@ -1158,10 +627,10 @@ app.post('/api/transfers/draft', requireAuth, async (req: Request, res: Response res.status(201).json({ ok: true, - bird: normalizeBird(birdResult.rows[0]), + bird: normalizeBird(bird), destinationOwnerEmail, - destinationOwnerExists: Boolean(existingUser.rowCount), - inviteSent: !existingUser.rowCount, + destinationOwnerExists: Boolean(existingUser), + inviteSent: !existingUser, invitePreviewUrl, inviteDelivery, }); @@ -1179,53 +648,25 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: } 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]; + const magicLink = await consumeMagicLinkToken(hashToken(rawToken)); 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]; + let user = await findUserByEmail(magicLink.email); 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]; + user = await createUser(magicLink.email, magicLink.name || magicLink.email.split('@')[0] || 'FlockPal User'); } 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]; + user = await updateUserName(user.id, magicLink.name); } - 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); + await claimWorkspaceInvites(user!); + const memberships = await normalizeWorkspaceMembershipList(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()); @@ -1234,16 +675,16 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: } }); -app.post('/api/auth/logout', requireAuth, async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/auth/logout', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { - await pool.query('DELETE FROM auth_sessions WHERE id = $1', [req.auth?.session.id]); + await deleteAuthSession(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) => { +app.get('/api/auth/session', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { res.json({ token: req.auth?.token, @@ -1254,7 +695,7 @@ app.get('/api/auth/session', requireAuth, async (req: Request, res: Response, ne } }); -app.post('/api/auth/switch-workspace', requireAuth, async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/auth/switch-workspace', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { const parsed = switchWorkspaceSchema.safeParse(req.body); if (!parsed.success) { @@ -1270,14 +711,9 @@ app.post('/api/auth/switch-workspace', requireAuth, async (req: Request, res: Re return; } - await pool.query( - `UPDATE auth_sessions - SET active_workspace_id = $2 - WHERE id = $1`, - [req.auth!.session.id, parsed.data.workspaceId], - ); + await updateSessionWorkspace(req.auth!.session.id, parsed.data.workspaceId); - const updatedAuth = await resolveAuth(req.auth!.token); + const updatedAuth = await resolveSessionAuth(hashToken(req.auth!.token), req.auth!.token); if (!updatedAuth) { throw new Error('Unable to reload session.'); @@ -1314,11 +750,7 @@ app.get('/api/auth/oauth/:provider/start', async (req: Request, res: Response, n 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], - ); + await createOAuthState(stateId, providerKey, codeVerifier, redirectTo, expiresAt); const authorizationUrl = new URL(provider.authorizationEndpoint); authorizationUrl.searchParams.set('client_id', provider.clientId); @@ -1357,16 +789,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti } 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]; + const oauthState = await consumeOAuthState(state, providerKey); if (!oauthState) { res.status(400).send('OAuth session is invalid or expired.'); @@ -1410,7 +833,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti 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; + 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'; @@ -1435,46 +858,20 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti 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]; + let user = await findUserByProviderAccount(providerKey, providerSubject); 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]; + user = await findUserByEmail(email); } - 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], - ); + if (!user) { + user = await createUser(email, name); + } - await claimWorkspaceInvites(user); - const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user); - const { token } = await createAuthSession(user.id, activeWorkspaceId); + await linkAuthAccount(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); @@ -1487,17 +884,73 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti 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) => { +app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { - res.json({ - workspaces: await listMembershipsForUser(req.auth!.user.id), + const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id); + res.json({ integrationTokens: tokens.map(normalizeIntegrationToken) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { + const parsed = integrationTokenCreateSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid integration token payload', details: parsed.error.flatten() }); + return; + } + + try { + const rawToken = createIntegrationToken(); + const expiresAt = parsed.data.expiresInDays + ? new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000).toISOString() + : null; + const integrationToken = await createIntegrationTokenRecord({ + userId: req.auth!.user.id, + workspaceId: req.auth!.workspace.id, + name: parsed.data.name, + tokenHash: hashToken(rawToken), + tokenPrefix: rawToken.slice(0, 16), + scope: parsed.data.scope, + expiresAt, + }); + + res.status(201).json({ + integrationToken: normalizeIntegrationToken(integrationToken!), + token: rawToken, }); } catch (error) { next(error); } }); -app.post('/api/workspaces', requireAuth, async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/integration-tokens/:tokenId', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const revoked = await revokeIntegrationToken(req.params.tokenId, req.auth!.user.id, req.auth!.workspace.id); + + if (!revoked) { + res.status(404).json({ error: 'Integration token not found.' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.get('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + res.json({ + workspaces: await normalizeWorkspaceMembershipList(req.auth!.user.id), + }); + } catch (error) { + next(error); + } +}); + +app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { const parsed = createWorkspaceSchema.safeParse(req.body); if (!parsed.success) { @@ -1508,20 +961,15 @@ app.post('/api/workspaces', requireAuth, async (req: Request, res: Response, nex try { const workspaceId = await getNextWorkspaceId(); const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan); + const workspace = await createWorkspace({ + id: workspaceId, + name: parsed.data.name, + workspaceType: parsed.data.workspaceType, + billingEmail: emptyToNull(parsed.data.billingEmail), + billingPlan, + owner: req.auth!.user, + }); - 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, email, invite_email, name, role, accepted_at) - VALUES ($1, $2, $3, $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); @@ -1532,7 +980,7 @@ 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) => { +app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { const parsed = workspaceSchema.safeParse(req.body); if (!parsed.success) { @@ -1542,20 +990,15 @@ app.put('/api/workspace', requireAuth, requireWorkspaceRole(['owner', 'manager'] try { const billingPlan = resolveBillingPlan(parsed.data.workspaceType, parsed.data.billingPlan ?? req.auth!.workspace.billing_plan); + const workspace = await updateWorkspace({ + workspaceId: req.auth!.workspace.id, + name: parsed.data.name, + workspaceType: parsed.data.workspaceType, + billingEmail: emptyToNull(parsed.data.billingEmail), + billingPlan, + }); - 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]) }); + res.json({ workspace: normalizeWorkspace(workspace!) }); } catch (error) { next(error); } @@ -1563,21 +1006,14 @@ app.put('/api/workspace', requireAuth, requireWorkspaceRole(['owner', 'manager'] 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, COALESCE(invite_email, email) AS 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) }); + const members = await listWorkspaceMembers(req.auth!.workspace.id); + res.json({ members: members.map(normalizeWorkspaceMember) }); } catch (error) { next(error); } }); -app.post('/api/workspace/members', requireAuth, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { const parsed = workspaceMemberSchema.safeParse(req.body); if (!parsed.success) { @@ -1587,52 +1023,26 @@ app.post('/api/workspace/members', requireAuth, requireWorkspaceRole(['owner', ' 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 existingUser = await findUserByEmail(inviteEmail); + const member = await upsertWorkspaceMember({ + workspaceId: req.auth!.workspace.id, + inviteEmail, + name: parsed.data.name, + role: parsed.data.role, + existingUser, + }); - const existingUserRow = existingUser.rows[0]; - const result = await pool.query( - `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) - VALUES ($1, $2, $3, $3, $4, $5, $6) - ON CONFLICT (workspace_id, invite_email) DO UPDATE - SET name = EXCLUDED.name, - role = EXCLUDED.role, - email = EXCLUDED.email, - 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, COALESCE(invite_email, email) AS 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]) }); + res.status(201).json({ member: normalizeWorkspaceMember(member!) }); } catch (error) { next(error); } }); -app.delete('/api/workspace/members/:memberId', requireAuth, requireWorkspaceRole(['owner', 'manager']), async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, 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], - ); + const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id); - if (!result.rowCount) { + if (!deleted) { res.status(404).json({ error: 'Workspace member not found or cannot be removed.' }); return; } @@ -1645,29 +1055,14 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWorkspaceRole 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 ( - SELECT weight_grams, recorded_on - FROM weight_records - WHERE weight_records.bird_id = birds.id - ORDER BY recorded_on DESC - LIMIT 1 - ) latest ON TRUE - WHERE birds.workspace_id = $1 - ORDER BY birds.name ASC`, - [req.auth!.workspace.id], - ); - - res.json({ birds: result.rows.map(normalizeBird) }); + const birds = await listBirds(req.auth!.workspace.id); + res.json({ birds: birds.map(normalizeBird) }); } catch (error) { next(error); } }); -app.post('/api/birds', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { @@ -1676,25 +1071,20 @@ app.post('/api/birds', requireAuth, requireWorkspaceRole(['owner', 'manager', 's } try { - const result = await pool.query( - `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, - emptyToNull(parsed.data.dateOfBirth), - emptyToNull(parsed.data.gotchaDay), - parsed.data.chartColor ?? '#cb3a35', - emptyToNull(parsed.data.photoDataUrl), - parsed.data.notifyOnDob ?? false, - parsed.data.notifyOnGotchaDay ?? false, - ], - ); + const bird = await createBird({ + workspaceId: req.auth!.workspace.id, + name: parsed.data.name, + tagId: parsed.data.tagId, + species: parsed.data.species, + dateOfBirth: emptyToNull(parsed.data.dateOfBirth), + gotchaDay: emptyToNull(parsed.data.gotchaDay), + chartColor: parsed.data.chartColor ?? '#cb3a35', + photoDataUrl: emptyToNull(parsed.data.photoDataUrl), + notifyOnDob: parsed.data.notifyOnDob ?? false, + notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, + }); - res.status(201).json({ bird: normalizeBird(result.rows[0]) }); + res.status(201).json({ bird: normalizeBird(bird!) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' }); @@ -1705,7 +1095,7 @@ app.post('/api/birds', requireAuth, requireWorkspaceRole(['owner', 'manager', 's } }); -app.put('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); if (!parsed.success) { @@ -1714,55 +1104,26 @@ app.put('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'manag } try { - const result = await pool.query( - `UPDATE birds - SET name = $2, - tag_id = $3, - species = $4, - date_of_birth = $5, - gotcha_day = $6, - chart_color = $7, - photo_data_url = $8, - notify_on_dob = $9, - notify_on_gotcha_day = $10 - WHERE id = $1 - 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 - WHERE bird_id = birds.id - ORDER BY recorded_on DESC - LIMIT 1 - ) AS latest_weight_grams, - ( - SELECT recorded_on::text - FROM weight_records - WHERE bird_id = birds.id - ORDER BY recorded_on DESC - LIMIT 1 - ) AS latest_recorded_on`, - [ - req.params.birdId, - parsed.data.name, - parsed.data.tagId, - parsed.data.species, - emptyToNull(parsed.data.dateOfBirth), - emptyToNull(parsed.data.gotchaDay), - parsed.data.chartColor ?? '#cb3a35', - emptyToNull(parsed.data.photoDataUrl), - parsed.data.notifyOnDob ?? false, - parsed.data.notifyOnGotchaDay ?? false, - req.auth!.workspace.id, - ], - ); + const bird = await updateBird({ + birdId: req.params.birdId, + workspaceId: req.auth!.workspace.id, + name: parsed.data.name, + tagId: parsed.data.tagId, + species: parsed.data.species, + dateOfBirth: emptyToNull(parsed.data.dateOfBirth), + gotchaDay: emptyToNull(parsed.data.gotchaDay), + chartColor: parsed.data.chartColor ?? '#cb3a35', + photoDataUrl: emptyToNull(parsed.data.photoDataUrl), + notifyOnDob: parsed.data.notifyOnDob ?? false, + notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, + }); - if (!result.rowCount) { + if (!bird) { res.status(404).json({ error: 'Bird not found.' }); return; } - res.json({ bird: normalizeBird(result.rows[0]) }); + res.json({ bird: normalizeBird(bird) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in this workspace.' }); @@ -1773,17 +1134,11 @@ app.put('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'manag } }); -app.delete('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, 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 - AND workspace_id = $2 - RETURNING id`, - [req.params.birdId, req.auth!.workspace.id], - ); + const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id); - if (!result.rowCount) { + if (!deleted) { res.status(404).json({ error: 'Bird not found.' }); return; } @@ -1797,28 +1152,14 @@ app.delete('/api/birds/:birdId', requireAuth, requireWorkspaceRole(['owner', 'ma 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.auth!.workspace.id], - ); - - res.json({ weights: result.rows.map(normalizeWeight) }); + const weights = await listWeightsForBird(req.params.birdId, req.auth!.workspace.id, days); + res.json({ weights: weights.map(normalizeWeight) }); } catch (error) { next(error); } }); -app.post('/api/birds/:birdId/weights', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = weightSchema.safeParse(req.body); if (!parsed.success) { @@ -1834,14 +1175,8 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWorkspaceRole(['owner return; } - const result = await pool.query( - `INSERT INTO weight_records (bird_id, weight_grams, recorded_on, notes) - VALUES ($1, $2, $3, $4) - RETURNING id, bird_id, weight_grams, recorded_on::text, notes`, - [req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)], - ); - - res.status(201).json({ weight: normalizeWeight(result.rows[0]) }); + const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); + res.status(201).json({ weight: normalizeWeight(weight!) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' }); @@ -1854,27 +1189,14 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWorkspaceRole(['owner 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.auth!.workspace.id], - ); - - res.json({ vetVisits: result.rows.map(normalizeVetVisit) }); + const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id); + res.json({ vetVisits: vetVisits.map(normalizeVetVisit) }); } catch (error) { next(error); } }); -app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { +app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'manager', 'staff']), async (req: Request, res: Response, next: NextFunction) => { const parsed = vetVisitSchema.safeParse(req.body); if (!parsed.success) { @@ -1890,14 +1212,15 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWorkspaceRole(['ow return; } - const result = await pool.query( - `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)], + const vetVisit = await createVetVisitForBird( + req.params.birdId, + parsed.data.visitedOn, + parsed.data.clinicName, + parsed.data.reason, + emptyToNull(parsed.data.notes), ); - res.status(201).json({ vetVisit: normalizeVetVisit(result.rows[0]) }); + res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) }); } catch (error) { next(error); } diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts new file mode 100644 index 0000000..6178e6c --- /dev/null +++ b/backend/src/db/client.ts @@ -0,0 +1,31 @@ +import dotenv from 'dotenv'; +import pg, { type QueryResult, type QueryResultRow } from 'pg'; + +dotenv.config(); + +type QueryParams = unknown[] | undefined; + +export class DatabaseClient { + private readonly pool: pg.Pool; + + constructor() { + const { Pool } = pg; + this.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', + }); + } + + query(text: string, params?: QueryParams): Promise> { + return this.pool.query(text, params); + } + + async close() { + await this.pool.end(); + } +} + +export const db = new DatabaseClient(); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts new file mode 100644 index 0000000..1a853d6 --- /dev/null +++ b/backend/src/db/schema.ts @@ -0,0 +1,226 @@ +import { db, type DatabaseClient } from './client.js'; + +export const ensureSchema = async (database: DatabaseClient = db) => { + await database.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 + ); + + ALTER TABLE workspace_members + ADD COLUMN IF NOT EXISTS email VARCHAR(255), + ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS invite_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ; + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'workspace_members' + AND column_name = 'email' + ) THEN + UPDATE workspace_members + SET invite_email = COALESCE(invite_email, email) + WHERE invite_email IS NULL; + END IF; + END $$; + + UPDATE workspace_members + SET invite_email = '' + WHERE invite_email IS NULL; + + UPDATE workspace_members + SET email = invite_email + WHERE email IS NULL; + + ALTER TABLE workspace_members + ALTER COLUMN invite_email SET NOT NULL; + + 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 integration_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + token_hash VARCHAR(255) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL, + scope VARCHAR(16) NOT NULL DEFAULT 'read_write', + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + ALTER TABLE integration_tokens + ADD COLUMN IF NOT EXISTS workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS name VARCHAR(160) NOT NULL DEFAULT 'Integration token', + ADD COLUMN IF NOT EXISTS token_prefix VARCHAR(32), + ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'read_write', + ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ; + + UPDATE integration_tokens + SET token_prefix = LEFT(token_hash, 12) + WHERE token_prefix IS NULL; + + CREATE INDEX IF NOT EXISTS idx_integration_tokens_user_workspace + ON integration_tokens (user_id, workspace_id, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_integration_tokens_lookup + ON integration_tokens (token_hash) + WHERE revoked_at IS NULL; + + 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, + 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 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(), + bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, + weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0), + recorded_on DATE NOT NULL, + notes VARCHAR(280), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (bird_id, recorded_on) + ); + + CREATE TABLE IF NOT EXISTS vet_visits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, + visited_on DATE NOT NULL, + clinic_name VARCHAR(160) NOT NULL, + reason VARCHAR(160) NOT NULL, + notes VARCHAR(1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on + ON weight_records (bird_id, recorded_on DESC); + + CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on + ON vet_visits (bird_id, visited_on DESC); + `); +}; diff --git a/backend/src/repositories/authRepository.test.ts b/backend/src/repositories/authRepository.test.ts new file mode 100644 index 0000000..3ac811c --- /dev/null +++ b/backend/src/repositories/authRepository.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createAuthSession, resolveAuth, resolveIntegrationTokenAuth } from './authRepository.js'; +import { mockDb } from '../test/mockDb.js'; + +test('resolveAuth returns null when session is missing', async () => { + const { calls } = mockDb({ rowCount: 0, rows: [] }); + + const result = await resolveAuth('hashed-token', 'raw-token'); + + assert.equal(result, null); + assert.equal(calls.length, 1); + assert.match(calls[0].text, /FROM auth_sessions/); + assert.deepEqual(calls[0].params, ['hashed-token']); +}); + +test('resolveIntegrationTokenAuth maps auth context and records last use', async () => { + const { calls } = mockDb( + { + rowCount: 1, + rows: [ + { + integration_token_id: 'token-1', + integration_token_user_id: 'user-1', + integration_token_workspace_id: 12, + integration_token_name: 'CLI token', + integration_token_token_hash: 'hashed-token', + integration_token_token_prefix: 'flpt_1234', + integration_token_scope: 'read_write', + integration_token_last_used_at: null, + integration_token_expires_at: '2026-05-01T00:00:00.000Z', + integration_token_revoked_at: null, + integration_token_created_at: '2026-04-01T00:00:00.000Z', + user_id_row: 'user-1', + user_email: 'owner@example.com', + user_password_hash: null, + user_name: 'Owner', + user_created_at: '2026-04-01T00:00:00.000Z', + workspace_id_row: 12, + workspace_name: 'Sanctuary', + workspace_workspace_type: 'rescue', + workspace_billing_email: 'billing@example.com', + workspace_billing_plan: 'rescue_free', + workspace_created_at: '2026-04-01T00:00:00.000Z', + workspace_updated_at: '2026-04-02T00:00:00.000Z', + membership_id_row: 'member-1', + membership_workspace_id: 12, + membership_user_id: 'user-1', + membership_invite_email: 'owner@example.com', + membership_name: 'Owner', + membership_role: 'owner', + membership_accepted_at: '2026-04-01T00:00:00.000Z', + membership_created_at: '2026-04-01T00:00:00.000Z', + }, + ], + }, + { rowCount: 1, rows: [] }, + ); + + const result = await resolveIntegrationTokenAuth('hashed-token', 'raw-token'); + + assert.ok(result); + assert.equal(result?.authType, 'integration_token'); + assert.equal(result?.token, 'raw-token'); + assert.equal(result?.integrationToken?.scope, 'read_write'); + assert.equal(result?.workspace.id, 12); + assert.equal(calls.length, 2); + assert.match(calls[0].text, /FROM integration_tokens/); + assert.match(calls[1].text, /UPDATE integration_tokens/); + assert.deepEqual(calls[1].params, ['token-1']); +}); + +test('createAuthSession persists the provided token hash and expiry', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [ + { + id: 'session-1', + user_id: 'user-1', + active_workspace_id: 5, + token_hash: 'hashed-token', + expires_at: '2026-05-01T00:00:00.000Z', + created_at: '2026-04-14T00:00:00.000Z', + }, + ], + }); + + const session = await createAuthSession('user-1', 5, 'hashed-token', '2026-05-01T00:00:00.000Z'); + + assert.equal(session?.id, 'session-1'); + assert.deepEqual(calls[0].params, ['user-1', 5, 'hashed-token', '2026-05-01T00:00:00.000Z']); +}); diff --git a/backend/src/repositories/authRepository.ts b/backend/src/repositories/authRepository.ts new file mode 100644 index 0000000..af09762 --- /dev/null +++ b/backend/src/repositories/authRepository.ts @@ -0,0 +1,478 @@ +import { db } from '../db/client.js'; +import type { + AuthContext, + AuthSessionRow, + BillingPlan, + IntegrationTokenRow, + IntegrationTokenScope, + MagicLinkTokenRow, + OAuthStateRow, + ProviderKey, + UserRow, + WorkspaceMemberRow, + WorkspaceRole, + WorkspaceRow, + WorkspaceType, +} from '../types.js'; + +const mapSessionAuthRow = ( + row: 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; + }, + token: string, +): AuthContext => ({ + 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, + authType: 'session', +}); + +const mapIntegrationTokenAuthRow = ( + row: IntegrationTokenRow & + UserRow & + WorkspaceRow & + WorkspaceMemberRow & { + integration_token_id: string; + integration_token_user_id: string; + integration_token_workspace_id: number; + integration_token_name: string; + integration_token_token_hash: string; + integration_token_token_prefix: string; + integration_token_scope: IntegrationTokenScope; + integration_token_last_used_at: string | null; + integration_token_expires_at: string | null; + integration_token_revoked_at: string | null; + integration_token_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; + }, + token: string, +): AuthContext => ({ + 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.integration_token_id, + user_id: row.integration_token_user_id, + active_workspace_id: row.integration_token_workspace_id, + token_hash: row.integration_token_token_hash, + expires_at: row.integration_token_expires_at ?? '', + created_at: row.integration_token_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, + authType: 'integration_token', + integrationToken: { + id: row.integration_token_id, + user_id: row.integration_token_user_id, + workspace_id: row.integration_token_workspace_id, + name: row.integration_token_name, + token_hash: row.integration_token_token_hash, + token_prefix: row.integration_token_token_prefix, + scope: row.integration_token_scope, + last_used_at: row.integration_token_last_used_at, + expires_at: row.integration_token_expires_at, + revoked_at: row.integration_token_revoked_at, + created_at: row.integration_token_created_at, + }, +}); + +export const createAuthSession = async (userId: string, activeWorkspaceId: number, tokenHash: string, expiresAt: string) => { + const result = await db.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], + ); + + return result.rows[0] ?? null; +}; + +export const deleteAuthSession = async (sessionId: string) => { + await db.query('DELETE FROM auth_sessions WHERE id = $1', [sessionId]); +}; + +export const updateSessionWorkspace = async (sessionId: string, workspaceId: number) => { + await db.query( + `UPDATE auth_sessions + SET active_workspace_id = $2 + WHERE id = $1`, + [sessionId, workspaceId], + ); +}; + +export const deleteExpiredMagicLinkTokens = async () => { + await db.query( + `DELETE FROM magic_link_tokens + WHERE expires_at <= CURRENT_TIMESTAMP`, + ); +}; + +export const createMagicLinkToken = async (email: string, name: string | null, tokenHash: string, redirectTo: string, expiresAt: string) => { + await db.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], + ); +}; + +export const consumeMagicLinkToken = async (tokenHash: string) => { + const result = await db.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`, + [tokenHash], + ); + + return result.rows[0] ?? null; +}; + +export const findUserByEmail = async (email: string) => { + const result = await db.query( + `SELECT id, email, password_hash, name, created_at + FROM users + WHERE email = $1`, + [email], + ); + + return result.rows[0] ?? null; +}; + +export const createUser = async (email: string, name: string) => { + const result = await db.query( + `INSERT INTO users (email, name) + VALUES ($1, $2) + RETURNING id, email, password_hash, name, created_at`, + [email, name], + ); + + return result.rows[0] ?? null; +}; + +export const updateUserName = async (userId: string, name: string) => { + const result = await db.query( + `UPDATE users + SET name = $2 + WHERE id = $1 + RETURNING id, email, password_hash, name, created_at`, + [userId, name], + ); + + return result.rows[0] ?? null; +}; + +export const createOAuthState = async (id: string, providerKey: ProviderKey, codeVerifier: string, redirectTo: string, expiresAt: string) => { + await db.query( + `INSERT INTO oauth_states (id, provider_key, code_verifier, redirect_to, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [id, providerKey, codeVerifier, redirectTo, expiresAt], + ); +}; + +export const consumeOAuthState = async (stateId: string, providerKey: ProviderKey) => { + const result = await db.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`, + [stateId, providerKey], + ); + + return result.rows[0] ?? null; +}; + +export const findUserByProviderAccount = async (providerKey: ProviderKey, providerSubject: string) => { + const result = await db.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], + ); + + return result.rows[0] ?? null; +}; + +export const linkAuthAccount = async (userId: string, providerKey: ProviderKey, providerSubject: string, providerEmail: string) => { + await db.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`, + [userId, providerKey, providerSubject, providerEmail], + ); +}; + +export const resolveAuth = async (tokenHash: string, token: string) => { + const result = await db.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, + COALESCE(workspace_members.invite_email, workspace_members.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`, + [tokenHash], + ); + + return result.rows[0] ? mapSessionAuthRow(result.rows[0], token) : null; +}; + +export const resolveIntegrationTokenAuth = async (tokenHash: string, token: string) => { + const result = await db.query< + IntegrationTokenRow & + UserRow & + WorkspaceRow & + WorkspaceMemberRow & { + integration_token_id: string; + integration_token_user_id: string; + integration_token_workspace_id: number; + integration_token_name: string; + integration_token_token_hash: string; + integration_token_token_prefix: string; + integration_token_scope: IntegrationTokenScope; + integration_token_last_used_at: string | null; + integration_token_expires_at: string | null; + integration_token_revoked_at: string | null; + integration_token_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 + integration_tokens.id AS integration_token_id, + integration_tokens.user_id AS integration_token_user_id, + integration_tokens.workspace_id AS integration_token_workspace_id, + integration_tokens.name AS integration_token_name, + integration_tokens.token_hash AS integration_token_token_hash, + integration_tokens.token_prefix AS integration_token_token_prefix, + integration_tokens.scope AS integration_token_scope, + integration_tokens.last_used_at::text AS integration_token_last_used_at, + integration_tokens.expires_at::text AS integration_token_expires_at, + integration_tokens.revoked_at::text AS integration_token_revoked_at, + integration_tokens.created_at AS integration_token_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, + COALESCE(workspace_members.invite_email, workspace_members.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 integration_tokens + INNER JOIN users ON users.id = integration_tokens.user_id + INNER JOIN workspaces ON workspaces.id = integration_tokens.workspace_id + INNER JOIN workspace_members + ON workspace_members.workspace_id = integration_tokens.workspace_id + AND workspace_members.user_id = integration_tokens.user_id + WHERE integration_tokens.token_hash = $1 + AND integration_tokens.revoked_at IS NULL + AND (integration_tokens.expires_at IS NULL OR integration_tokens.expires_at > CURRENT_TIMESTAMP)`, + [tokenHash], + ); + + if (!result.rows[0]) { + return null; + } + + await db.query( + `UPDATE integration_tokens + SET last_used_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [result.rows[0].integration_token_id], + ); + + return mapIntegrationTokenAuthRow(result.rows[0], token); +}; diff --git a/backend/src/repositories/birdRepository.test.ts b/backend/src/repositories/birdRepository.test.ts new file mode 100644 index 0000000..5e8ed69 --- /dev/null +++ b/backend/src/repositories/birdRepository.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createBird, getBirdById, listWeightsForBird } from './birdRepository.js'; +import { mockDb } from '../test/mockDb.js'; + +test('getBirdById returns null when the bird does not exist in the workspace', async () => { + const { calls } = mockDb({ rowCount: 0, rows: [] }); + + const bird = await getBirdById('bird-1', 10); + + assert.equal(bird, null); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].params, ['bird-1', 10]); +}); + +test('createBird returns the inserted bird row', async () => { + mockDb({ + rowCount: 1, + rows: [ + { + id: 'bird-1', + workspace_id: 10, + name: 'Kiwi', + tag_id: 'A-1', + species: 'Cockatiel', + date_of_birth: null, + gotcha_day: null, + chart_color: '#cb3a35', + photo_data_url: null, + notify_on_dob: false, + notify_on_gotcha_day: false, + created_at: '2026-04-14T00:00:00.000Z', + latest_weight_grams: null, + latest_recorded_on: null, + }, + ], + }); + + const bird = await createBird({ + workspaceId: 10, + name: 'Kiwi', + tagId: 'A-1', + species: 'Cockatiel', + dateOfBirth: null, + gotchaDay: null, + chartColor: '#cb3a35', + photoDataUrl: null, + notifyOnDob: false, + notifyOnGotchaDay: false, + }); + + assert.equal(bird?.name, 'Kiwi'); + assert.equal(bird?.workspace_id, 10); +}); + +test('listWeightsForBird scopes by bird, workspace, and day window', async () => { + const { calls } = mockDb({ + rowCount: 0, + rows: [], + }); + + const weights = await listWeightsForBird('bird-1', 10, 30); + + assert.deepEqual(weights, []); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].params, ['bird-1', 30, 10]); + assert.match(calls[0].text, /FROM weight_records/); +}); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts new file mode 100644 index 0000000..aaa8772 --- /dev/null +++ b/backend/src/repositories/birdRepository.ts @@ -0,0 +1,222 @@ +import { db } from '../db/client.js'; +import type { BirdRow, VetVisitRow, WeightRow } from '../types.js'; + +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 +`; + +export const getBirdById = async (birdId: string, workspaceId: number) => { + const result = await db.query( + `SELECT + ${birdSelectFields} + FROM birds + LEFT JOIN LATERAL ( + SELECT weight_grams, recorded_on + FROM weight_records + WHERE weight_records.bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) latest ON TRUE + WHERE birds.id = $1 + AND birds.workspace_id = $2`, + [birdId, workspaceId], + ); + + return result.rows[0] ?? null; +}; + +export const listBirds = async (workspaceId: number) => { + const result = await db.query( + `SELECT + ${birdSelectFields} + FROM birds + LEFT JOIN LATERAL ( + SELECT weight_grams, recorded_on + FROM weight_records + WHERE weight_records.bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) latest ON TRUE + WHERE birds.workspace_id = $1 + ORDER BY birds.name ASC`, + [workspaceId], + ); + + return result.rows; +}; + +export const createBird = async ({ + workspaceId, + name, + tagId, + species, + dateOfBirth, + gotchaDay, + chartColor, + photoDataUrl, + notifyOnDob, + notifyOnGotchaDay, +}: { + workspaceId: number; + name: string; + tagId: string; + species: string; + dateOfBirth: string | null; + gotchaDay: string | null; + chartColor: string; + photoDataUrl: string | null; + notifyOnDob: boolean; + notifyOnGotchaDay: boolean; +}) => { + const result = await db.query( + `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`, + [workspaceId, name, tagId, species, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay], + ); + + return result.rows[0] ?? null; +}; + +export const updateBird = async ({ + birdId, + workspaceId, + name, + tagId, + species, + dateOfBirth, + gotchaDay, + chartColor, + photoDataUrl, + notifyOnDob, + notifyOnGotchaDay, +}: { + birdId: string; + workspaceId: number; + name: string; + tagId: string; + species: string; + dateOfBirth: string | null; + gotchaDay: string | null; + chartColor: string; + photoDataUrl: string | null; + notifyOnDob: boolean; + notifyOnGotchaDay: boolean; +}) => { + const result = await db.query( + `UPDATE birds + SET name = $2, + tag_id = $3, + species = $4, + date_of_birth = $5, + gotcha_day = $6, + chart_color = $7, + photo_data_url = $8, + notify_on_dob = $9, + notify_on_gotcha_day = $10 + WHERE id = $1 + 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 + WHERE bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) AS latest_weight_grams, + ( + SELECT recorded_on::text + FROM weight_records + WHERE bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) AS latest_recorded_on`, + [birdId, name, tagId, species, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId], + ); + + return result.rows[0] ?? null; +}; + +export const deleteBird = async (birdId: string, workspaceId: number) => { + const result = await db.query<{ id: string }>( + `DELETE FROM birds + WHERE id = $1 + AND workspace_id = $2 + RETURNING id`, + [birdId, workspaceId], + ); + + return Boolean(result.rowCount); +}; + +export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => { + const result = await db.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`, + [birdId, days, workspaceId], + ); + + return result.rows; +}; + +export const createWeightForBird = async (birdId: string, weightGrams: number, recordedOn: string, notes: string | null) => { + const result = await db.query( + `INSERT INTO weight_records (bird_id, weight_grams, recorded_on, notes) + VALUES ($1, $2, $3, $4) + RETURNING id, bird_id, weight_grams, recorded_on::text, notes`, + [birdId, weightGrams, recordedOn, notes], + ); + + return result.rows[0] ?? null; +}; + +export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => { + const result = await db.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`, + [birdId, workspaceId], + ); + + return result.rows; +}; + +export const createVetVisitForBird = async (birdId: string, visitedOn: string, clinicName: string, reason: string, notes: string | null) => { + const result = await db.query( + `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`, + [birdId, visitedOn, clinicName, reason, notes], + ); + + return result.rows[0] ?? null; +}; diff --git a/backend/src/repositories/integrationTokenRepository.ts b/backend/src/repositories/integrationTokenRepository.ts new file mode 100644 index 0000000..bddc173 --- /dev/null +++ b/backend/src/repositories/integrationTokenRepository.ts @@ -0,0 +1,58 @@ +import { db } from '../db/client.js'; +import type { IntegrationTokenRow, IntegrationTokenScope } from '../types.js'; + +export const listIntegrationTokens = async (userId: string, workspaceId: number) => { + const result = await db.query( + `SELECT id, user_id, workspace_id, name, token_hash, token_prefix, scope, last_used_at::text, expires_at::text, revoked_at::text, created_at + FROM integration_tokens + WHERE user_id = $1 + AND workspace_id = $2 + AND revoked_at IS NULL + ORDER BY created_at DESC`, + [userId, workspaceId], + ); + + return result.rows; +}; + +export const createIntegrationTokenRecord = async ({ + userId, + workspaceId, + name, + tokenHash, + tokenPrefix, + scope, + expiresAt, +}: { + userId: string; + workspaceId: number; + name: string; + tokenHash: string; + tokenPrefix: string; + scope: IntegrationTokenScope; + expiresAt: string | null; +}) => { + const result = await db.query( + `INSERT INTO integration_tokens (user_id, workspace_id, name, token_hash, token_prefix, scope, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, user_id, workspace_id, name, token_hash, token_prefix, scope, last_used_at::text, expires_at::text, revoked_at::text, created_at`, + [userId, workspaceId, name, tokenHash, tokenPrefix, scope, expiresAt], + ); + + return result.rows[0] ?? null; +}; + +export const revokeIntegrationToken = async (tokenId: string, userId: string, workspaceId: number) => { + const result = await db.query<{ id: string }>( + `UPDATE integration_tokens + SET revoked_at = CURRENT_TIMESTAMP + WHERE id = $1 + AND user_id = $2 + AND workspace_id = $3 + AND revoked_at IS NULL + RETURNING id`, + [tokenId, userId, workspaceId], + ); + + return Boolean(result.rowCount); +}; diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts new file mode 100644 index 0000000..092e8fc --- /dev/null +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createWorkspace, ensurePersonalWorkspaceForUser } from './workspaceRepository.js'; +import { mockDb } from '../test/mockDb.js'; +import type { UserRow } from '../types.js'; + +const user: UserRow = { + id: 'user-1', + email: 'owner@example.com', + password_hash: null, + name: 'Owner', + created_at: '2026-04-14T00:00:00.000Z', +}; + +test('ensurePersonalWorkspaceForUser returns an existing workspace without creating one', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [{ workspace_id: 42 }], + }); + + const workspaceId = await ensurePersonalWorkspaceForUser(user); + + assert.equal(workspaceId, 42); + assert.equal(calls.length, 1); + assert.match(calls[0].text, /FROM workspace_members/); +}); + +test('createWorkspace inserts owner membership and returns the created workspace', async () => { + const { calls } = mockDb( + { rowCount: 1, rows: [] }, + { rowCount: 1, rows: [] }, + { + rowCount: 1, + rows: [ + { + id: 9, + name: 'My Rescue', + workspace_type: 'rescue', + billing_email: 'billing@example.com', + billing_plan: 'rescue_free', + created_at: '2026-04-14T00:00:00.000Z', + updated_at: '2026-04-14T00:00:00.000Z', + }, + ], + }, + ); + + const workspace = await createWorkspace({ + id: 9, + name: 'My Rescue', + workspaceType: 'rescue', + billingEmail: 'billing@example.com', + billingPlan: 'rescue_free', + owner: user, + }); + + assert.equal(workspace?.id, 9); + assert.equal(calls.length, 3); + assert.match(calls[0].text, /INSERT INTO workspaces/); + assert.match(calls[1].text, /INSERT INTO workspace_members/); + assert.match(calls[2].text, /SELECT id, name, workspace_type/); +}); diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts new file mode 100644 index 0000000..3706794 --- /dev/null +++ b/backend/src/repositories/workspaceRepository.ts @@ -0,0 +1,251 @@ +import { db } from '../db/client.js'; +import type { BillingPlan, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js'; + +export const getNextWorkspaceId = async () => { + const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); + return Number(result.rows[0]?.next_id ?? 1); +}; + +export const getWorkspaceById = async (workspaceId: number) => { + const result = await db.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; +}; + +export const getMembershipForUser = async (userId: string, workspaceId: number) => { + const result = await db.query( + `SELECT id, workspace_id, user_id, COALESCE(invite_email, email) AS 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; +}; + +export const listMembershipsForUser = async (userId: string) => { + const result = await db.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, + COALESCE(workspace_members.invite_email, workspace_members.email) AS 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; +}; + +export const ensurePersonalWorkspaceForUser = async (user: UserRow) => { + const existing = await db.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 db.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 db.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 db.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 db.query( + `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) + VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP) + ON CONFLICT (workspace_id, invite_email) DO UPDATE + SET user_id = EXCLUDED.user_id, + email = EXCLUDED.email, + name = EXCLUDED.name, + role = 'owner', + accepted_at = CURRENT_TIMESTAMP`, + [workspaceId, user.id, user.email, user.name], + ); + + return workspaceId; +}; + +export const claimWorkspaceInvites = async (user: UserRow) => { + await db.query( + `UPDATE workspace_members + SET user_id = $1, + accepted_at = CURRENT_TIMESTAMP + WHERE LOWER(COALESCE(invite_email, email)) = LOWER($2) + AND user_id IS NULL`, + [user.id, user.email], + ); +}; + +export const createWorkspace = async ({ + id, + name, + workspaceType, + billingEmail, + billingPlan, + owner, +}: { + id: number; + name: string; + workspaceType: WorkspaceType; + billingEmail: string | null; + billingPlan: BillingPlan; + owner: UserRow; +}) => { + await db.query( + `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan) + VALUES ($1, $2, $3, $4, $5)`, + [id, name, workspaceType, billingEmail, billingPlan], + ); + + await db.query( + `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) + VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP)`, + [id, owner.id, owner.email, owner.name], + ); + + return getWorkspaceById(id); +}; + +export const updateWorkspace = async ({ + workspaceId, + name, + workspaceType, + billingEmail, + billingPlan, +}: { + workspaceId: number; + name: string; + workspaceType: WorkspaceType; + billingEmail: string | null; + billingPlan: BillingPlan; +}) => { + const result = await db.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`, + [workspaceId, name, workspaceType, billingEmail, billingPlan], + ); + + return result.rows[0] ?? null; +}; + +export const listWorkspaceMembers = async (workspaceId: number) => { + const result = await db.query( + `SELECT id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at + FROM workspace_members + WHERE workspace_id = $1 + ORDER BY created_at ASC`, + [workspaceId], + ); + + return result.rows; +}; + +export const upsertWorkspaceMember = async ({ + workspaceId, + inviteEmail, + name, + role, + existingUser, +}: { + workspaceId: number; + inviteEmail: string; + name: string; + role: WorkspaceMemberRow['role']; + existingUser: UserRow | null; +}) => { + const result = await db.query( + `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) + VALUES ($1, $2, $3, $3, $4, $5, $6) + ON CONFLICT (workspace_id, invite_email) DO UPDATE + SET name = EXCLUDED.name, + role = EXCLUDED.role, + email = EXCLUDED.email, + 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, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`, + [workspaceId, existingUser?.id ?? null, inviteEmail, name, role, existingUser ? new Date().toISOString() : null], + ); + + return result.rows[0] ?? null; +}; + +export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => { + const result = await db.query<{ id: string }>( + `DELETE FROM workspace_members + WHERE id = $1 + AND workspace_id = $2 + AND role <> 'owner' + RETURNING id`, + [memberId, workspaceId], + ); + + return Boolean(result.rowCount); +}; diff --git a/backend/src/test/mockDb.ts b/backend/src/test/mockDb.ts new file mode 100644 index 0000000..86ae729 --- /dev/null +++ b/backend/src/test/mockDb.ts @@ -0,0 +1,52 @@ +import { afterEach } from 'node:test'; + +import { db } from '../db/client.js'; + +type QueryCall = { + text: string; + params: unknown[] | undefined; +}; + +type MockQueryResult = { + rowCount?: number; + rows?: unknown[]; +}; + +type MockHandler = MockQueryResult | ((call: QueryCall) => MockQueryResult | Promise); + +const originalQuery = db.query.bind(db); + +export const mockDb = (...handlers: MockHandler[]) => { + const calls: QueryCall[] = []; + const queue = [...handlers]; + + const mockedQuery = async (text: string, params?: unknown[]) => { + const call = { text, params }; + calls.push(call); + + const next = queue.shift(); + + if (!next) { + throw new Error(`Unexpected query: ${text}`); + } + + const result = typeof next === 'function' ? await next(call) : next; + + return { + rowCount: result.rowCount ?? result.rows?.length ?? 0, + rows: result.rows ?? [], + }; + }; + + (db as typeof db & { + query: typeof db.query; + }).query = mockedQuery as typeof db.query; + + return { calls }; +}; + +afterEach(() => { + (db as typeof db & { + query: typeof originalQuery; + }).query = originalQuery; +}); diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..6a595cf --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,128 @@ +export type WorkspaceType = 'standard' | 'rescue'; +export type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; +export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; +export type ProviderKey = 'google' | 'microsoft' | 'apple'; +export type IntegrationTokenScope = 'read_only' | 'read_write'; + +export type UserRow = { + id: string; + email: string; + password_hash: string | null; + name: string; + created_at: string; +}; + +export type WorkspaceRow = { + id: number; + name: string; + workspace_type: WorkspaceType; + billing_email: string | null; + billing_plan: BillingPlan; + created_at: string; + updated_at: string; +}; + +export 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; +}; + +export type AuthSessionRow = { + id: string; + user_id: string; + active_workspace_id: number; + token_hash: string; + expires_at: string; + created_at: string; +}; + +export type AuthAccountRow = { + id: string; + user_id: string; + provider_key: ProviderKey; + provider_subject: string; + provider_email: string | null; + created_at: string; +}; + +export type OAuthStateRow = { + id: string; + provider_key: ProviderKey; + code_verifier: string; + redirect_to: string; + expires_at: string; +}; + +export type MagicLinkTokenRow = { + id: string; + email: string; + name: string | null; + token_hash: string; + redirect_to: string; + expires_at: string; + created_at: string; +}; + +export type IntegrationTokenRow = { + id: string; + user_id: string; + workspace_id: number; + name: string; + token_hash: string; + token_prefix: string; + scope: IntegrationTokenScope; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; + created_at: string; +}; + +export type BirdRow = { + id: string; + workspace_id: number; + name: string; + tag_id: string; + species: string; + date_of_birth: string | null; + 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; +}; + +export type WeightRow = { + id: string; + bird_id: string; + weight_grams: string; + recorded_on: string; + notes: string | null; +}; + +export type VetVisitRow = { + id: string; + bird_id: string; + visited_on: string; + clinic_name: string; + reason: string; + notes: string | null; +}; + +export type AuthContext = { + user: UserRow; + session: AuthSessionRow; + workspace: WorkspaceRow; + membership: WorkspaceMemberRow; + token: string; + authType: 'session' | 'integration_token'; + integrationToken?: IntegrationTokenRow; +}; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..8c73c20 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,929 @@ +# FlockPal API Reference + +This document describes the HTTP API currently implemented in `backend/src/app.ts`. + +## Base URLs + +- Development frontend: `http://localhost:3000` +- Development API: `http://localhost:5000` +- Production API: use your configured `BACKEND_URL` + +## Authentication + +Most endpoints require a bearer token: + +```http +Authorization: Bearer +``` + +Tokens are created after either: + +- a successful magic-link sign-in +- a successful OAuth sign-in with Google, Microsoft, or Apple + +The backend redirects the browser back to the frontend with an `auth_token` query parameter after successful sign-in. Clients should store that token and send it as a bearer token on authenticated requests. + +If authentication is missing or invalid, the API returns: + +```json +{ "error": "Authentication required." } +``` + +FlockPal now supports two bearer token types: + +- browser session tokens returned after magic-link or OAuth sign-in +- integration tokens created from the Settings UI for automation tools like n8n + +Integration tokens are workspace-scoped and support: + +- `read_only` +- `read_write` + +## How `auth_token` Is Issued + +FlockPal issues the bearer token on the backend after a successful passwordless sign-in. The client does not generate it. + +### Magic-link flow + +1. The client calls `POST /api/auth/magic-link/request`. +2. The backend creates a short-lived magic-link token and emails it, or returns a preview URL in local development. +3. The user opens the magic link. +4. `GET /api/auth/magic-link/verify` validates the magic-link token, creates an auth session, and generates a new `auth_token`. +5. The backend redirects to the frontend with `auth_token` in the query string. + +Example redirect: + +```text +http://localhost:3000/?auth_token=YOUR_SESSION_TOKEN +``` + +### OAuth flow + +1. The client sends the user to `GET /api/auth/oauth/{provider}/start`. +2. The provider authenticates the user and redirects back to the backend callback. +3. The backend callback creates an auth session and generates a new `auth_token`. +4. The backend redirects to the frontend with `auth_token` in the query string. + +Example redirect: + +```text +http://localhost:3000/?auth_token=YOUR_SESSION_TOKEN +``` + +### How the token is used + +After the frontend receives `auth_token`, it should store it and send it on authenticated requests: + +```http +Authorization: Bearer YOUR_SESSION_TOKEN +``` + +### Important implementation note + +The backend stores only a hash of the session token in the database. The raw token is returned to the client once when the session is created. + +Integration tokens follow the same bearer-token header format, but they are created separately and are intended for scripts and automation tools rather than browser login. + +## Roles + +Workspace roles used by protected endpoints: + +- `owner` +- `manager` +- `staff` +- `viewer` + +Role requirements are called out per endpoint below. If the signed-in member lacks permission, the API returns: + +```json +{ "error": "You do not have permission for that action." } +``` + +## Data Shapes + +### User + +```json +{ + "id": "uuid", + "email": "person@example.com", + "name": "Taylor", + "createdAt": "2026-04-14T12:34:56.000Z" +} +``` + +### Workspace + +```json +{ + "id": 1001, + "name": "Home Flock", + "workspaceType": "standard", + "billingEmail": "billing@example.com", + "billingPlan": "household_basic", + "createdAt": "2026-04-14T12:34:56.000Z", + "updatedAt": "2026-04-14T12:34:56.000Z" +} +``` + +### Workspace Member + +```json +{ + "id": "uuid", + "workspaceId": 1001, + "userId": "uuid", + "inviteEmail": "member@example.com", + "name": "Alex", + "role": "viewer", + "acceptedAt": "2026-04-14T12:34:56.000Z", + "createdAt": "2026-04-14T12:34:56.000Z" +} +``` + +### Bird + +```json +{ + "id": "uuid", + "workspaceId": 1001, + "name": "Kiwi", + "tagId": "FP-001", + "species": "Cockatiel", + "dateOfBirth": "2023-05-10", + "gotchaDay": "2023-08-21", + "chartColor": "#cb3a35", + "photoDataUrl": null, + "notifyOnDob": false, + "notifyOnGotchaDay": true, + "createdAt": "2026-04-14T12:34:56.000Z", + "latestWeightGrams": 92, + "latestRecordedOn": "2026-04-14" +} +``` + +### Weight + +```json +{ + "id": "uuid", + "birdId": "uuid", + "weightGrams": 92, + "recordedOn": "2026-04-14", + "notes": "Morning check" +} +``` + +### Vet Visit + +```json +{ + "id": "uuid", + "birdId": "uuid", + "visitedOn": "2026-04-14", + "clinicName": "Avian Care Center", + "reason": "Wellness exam", + "notes": "Healthy" +} +``` + +## Common Validation Rules + +- Dates use `YYYY-MM-DD` +- `workspaceType` is `standard` or `rescue` +- member `role` is `owner`, `manager`, `staff`, or `viewer` +- bird `chartColor` must be a `#RRGGBB` hex color +- `photoDataUrl` must be a base64 `data:image/...` URL +- `weightGrams` must be a positive number up to `10000` + +Validation failures return `400` with this shape: + +```json +{ + "error": "Invalid ... payload", + "details": {} +} +``` + +## Endpoints + +### Health + +#### `GET /api/health` + +Public health check. + +Response `200`: + +```json +{ "ok": true } +``` + +### Authentication + +#### `GET /api/auth/providers` + +Public list of configured OAuth providers. + +Response `200`: + +```json +{ + "providers": [ + { + "providerKey": "google", + "displayName": "Google", + "enabled": true + } + ] +} +``` + +#### `POST /api/auth/register` + +Password registration is disabled. + +Response `410`: + +```json +{ + "error": "Password-based registration is disabled. Use a magic link or an identity provider." +} +``` + +#### `POST /api/auth/login` + +Password sign-in is disabled. + +Response `410`: + +```json +{ + "error": "Password-based sign-in is disabled. Use a magic link or an identity provider." +} +``` + +#### `POST /api/auth/magic-link/request` + +Starts a passwordless sign-in flow. + +Request body: + +```json +{ + "email": "person@example.com", + "name": "Taylor", + "redirectTo": "http://localhost:3000" +} +``` + +Notes: + +- `name` is optional +- `redirectTo` is optional and defaults to the frontend base URL +- if email delivery is not configured, the API returns a preview URL instead + +Response `202`: + +```json +{ + "ok": true, + "message": "If that address can sign in, a magic link is on the way.", + "previewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...", + "delivery": "preview" +} +``` + +`curl` example: + +```bash +curl -X POST http://localhost:5000/api/auth/magic-link/request \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "person@example.com", + "name": "Taylor", + "redirectTo": "http://localhost:3000" + }' +``` + +Local-development example response when SMTP is not configured: + +```json +{ + "ok": true, + "message": "If that address can sign in, a magic link is on the way.", + "previewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...", + "delivery": "preview" +} +``` + +#### `GET /api/auth/magic-link/verify?token=...` + +Consumes a single-use magic-link token, creates or loads the user, creates a session, and redirects to the frontend with `auth_token` in the query string. + +Responses: + +- `302` redirect on success +- `400` if the token is missing, invalid, or expired + +If you are testing locally and received a `previewUrl`, open that URL in a browser or inspect the redirect target to capture the `auth_token`. + +#### `POST /api/auth/logout` + +Requires auth. Invalidates the current session. + +Response `204` with no body. + +#### `GET /api/auth/session` + +Requires auth. Returns the current session context. + +Response `200`: + +```json +{ + "token": "raw-session-token", + "session": { + "user": { + "id": "uuid", + "email": "person@example.com", + "name": "Taylor", + "createdAt": "2026-04-14T12:34:56.000Z" + }, + "activeWorkspace": { + "id": 1001, + "name": "Home Flock", + "workspaceType": "standard", + "billingEmail": null, + "billingPlan": "household_basic", + "createdAt": "2026-04-14T12:34:56.000Z", + "updatedAt": "2026-04-14T12:34:56.000Z" + }, + "activeMembership": { + "id": "uuid", + "workspaceId": 1001, + "userId": "uuid", + "inviteEmail": "person@example.com", + "name": "Taylor", + "role": "owner", + "acceptedAt": "2026-04-14T12:34:56.000Z", + "createdAt": "2026-04-14T12:34:56.000Z" + }, + "workspaces": [], + "providers": [] + } +} +``` + +`curl` example: + +```bash +curl http://localhost:5000/api/auth/session \ + -H 'Authorization: Bearer YOUR_SESSION_TOKEN' +``` + +#### `POST /api/auth/switch-workspace` + +Requires auth. Switches the session's active workspace. + +Request body: + +```json +{ + "workspaceId": 1002 +} +``` + +Response `200` returns the same shape as `GET /api/auth/session`. + +`curl` example: + +```bash +curl -X POST http://localhost:5000/api/auth/switch-workspace \ + -H 'Authorization: Bearer YOUR_SESSION_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "workspaceId": 1002 + }' +``` + +Possible errors: + +- `403` if the user is not a member of the requested workspace + +#### `GET /api/auth/oauth/{provider}/start` + +Starts an OAuth login flow and redirects to the external identity provider. + +Path params: + +- `provider`: `google`, `microsoft`, or `apple` + +Concrete examples: + +- `/api/auth/oauth/google/start` +- `/api/auth/oauth/microsoft/start` +- `/api/auth/oauth/apple/start` + +Query params: + +- `redirectTo` optional frontend redirect target after successful login + +Responses: + +- `302` redirect to provider on success +- `404` for an unknown provider +- `400` if the provider is not configured + +Browser-oriented example: + +```text +http://localhost:5000/api/auth/oauth/google/start?redirectTo=http://localhost:3000 +``` + +`curl` can show the initial redirect, but this flow is meant to complete in a browser: + +```bash +curl -i "http://localhost:5000/api/auth/oauth/google/start?redirectTo=http://localhost:3000" +``` + +#### `GET /api/auth/oauth/{provider}/callback` +#### `POST /api/auth/oauth/{provider}/callback` + +OAuth callback used by providers. On success, the backend redirects to the frontend with `auth_token` in the query string. + +Path params: + +- `provider`: `google`, `microsoft`, or `apple` + +Responses: + +- `302` redirect on success +- `400` for missing or expired OAuth state +- `404` for unknown provider + +### Transfers + +#### `POST /api/transfers/draft` + +Requires auth. Prepares a bird transfer to another owner email and optionally triggers a magic-link invite for that email when no user exists yet. + +Request body: + +```json +{ + "birdId": "uuid", + "destinationOwnerEmail": "new-owner@example.com", + "notes": "Optional draft note" +} +``` + +Response `201`: + +```json +{ + "ok": true, + "bird": {}, + "destinationOwnerEmail": "new-owner@example.com", + "destinationOwnerExists": false, + "inviteSent": true, + "invitePreviewUrl": "http://localhost:5000/api/auth/magic-link/verify?token=...", + "inviteDelivery": "preview" +} +``` + +Possible errors: + +- `404` if the bird is not in the active workspace + +### Workspaces + +#### `GET /api/workspaces` + +Requires auth. Lists the signed-in user's workspace memberships. + +Response `200`: + +```json +{ + "workspaces": [] +} +``` + +#### `POST /api/workspaces` + +Requires auth. Creates a new workspace and makes the current user its `owner`. + +Request body: + +```json +{ + "name": "Home Flock", + "workspaceType": "standard", + "billingEmail": "billing@example.com", + "billingPlan": "household_plus" +} +``` + +Notes: + +- `workspaceType` must be `standard` or `rescue` +- `billingPlan` may be `household_basic`, `household_plus`, or `household_macaw` +- rescue workspaces are forced to `rescue_free` + +Response `201`: + +```json +{ + "workspace": {} +} +``` + +#### `GET /api/workspace` + +Requires auth. Returns the active workspace. Browser sessions and integration tokens can both use this endpoint. + +Response `200`: + +```json +{ + "workspace": {} +} +``` + +#### `PUT /api/workspace` + +Requires auth and role `owner` or `manager`. Updates the active workspace. + +Request body: + +```json +{ + "name": "Updated Flock", + "workspaceType": "standard", + "billingEmail": "billing@example.com", + "billingPlan": "household_basic" +} +``` + +Response `200`: + +```json +{ + "workspace": {} +} +``` + +#### `GET /api/workspace/members` + +Requires auth. Lists members for the active workspace. Browser sessions and integration tokens can both use this endpoint. + +Response `200`: + +```json +{ + "members": [] +} +``` + +#### `POST /api/workspace/members` + +Requires auth and role `owner` or `manager`. Invites or upserts a workspace member. + +Request body: + +```json +{ + "name": "Alex", + "email": "alex@example.com", + "role": "viewer" +} +``` + +Response `201`: + +```json +{ + "member": {} +} +``` + +#### `DELETE /api/workspace/members/:memberId` + +Requires auth and role `owner` or `manager`. Removes a non-owner member. + +Response `204` with no body. + +Possible errors: + +- `404` if the member was not found or is an owner + +### Birds + +#### `GET /api/birds` + +Requires auth. Lists birds in the active workspace. Browser sessions and integration tokens can both use this endpoint. + +Response `200`: + +```json +{ + "birds": [] +} +``` + +#### `POST /api/birds` + +Requires auth and role `owner`, `manager`, or `staff`. Creates a bird. + +Request body: + +```json +{ + "name": "Kiwi", + "tagId": "FP-001", + "species": "Cockatiel", + "dateOfBirth": "2023-05-10", + "gotchaDay": "2023-08-21", + "chartColor": "#cb3a35", + "photoDataUrl": "", + "notifyOnDob": false, + "notifyOnGotchaDay": true +} +``` + +Notes: + +- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings +- `chartColor` defaults to `#cb3a35` + +Response `201`: + +```json +{ + "bird": {} +} +``` + +Possible errors: + +- `409` if the workspace already uses that `tagId` + +#### `PUT /api/birds/:birdId` + +Requires auth and role `owner`, `manager`, or `staff`. Updates a bird. + +Request body matches `POST /api/birds`. + +Response `200`: + +```json +{ + "bird": {} +} +``` + +Possible errors: + +- `404` if the bird does not exist in the active workspace +- `409` if the workspace already uses that `tagId` + +#### `DELETE /api/birds/:birdId` + +Requires auth and role `owner`, `manager`, or `staff`. Deletes a bird. + +Response `204` with no body. + +Possible errors: + +- `404` if the bird does not exist in the active workspace + +### Weights + +#### `GET /api/birds/:birdId/weights` + +Requires auth. Lists weight entries for a bird in the active workspace. + +Query params: + +- `days` optional, clamped to `1` through `365`, default `30` + +Response `200`: + +```json +{ + "weights": [] +} +``` + +#### `POST /api/birds/:birdId/weights` + +Requires auth and role `owner`, `manager`, or `staff`. Creates a weight entry. + +Request body: + +```json +{ + "weightGrams": 92, + "recordedOn": "2026-04-14", + "notes": "Morning check" +} +``` + +Response `201`: + +```json +{ + "weight": {} +} +``` + +Possible errors: + +- `404` if the bird does not exist in the active workspace +- `409` if a weight already exists for that bird on that date + +### Vet Visits + +#### `GET /api/birds/:birdId/vet-visits` + +Requires auth. Lists vet visits for a bird in the active workspace. + +Response `200`: + +```json +{ + "vetVisits": [] +} +``` + +#### `POST /api/birds/:birdId/vet-visits` + +Requires auth and role `owner`, `manager`, or `staff`. Creates a vet visit. + +Request body: + +```json +{ + "visitedOn": "2026-04-14", + "clinicName": "Avian Care Center", + "reason": "Wellness exam", + "notes": "Healthy" +} +``` + +Response `201`: + +```json +{ + "vetVisit": {} +} +``` + +Possible errors: + +- `404` if the bird does not exist in the active workspace + +### Integration Tokens + +These endpoints are for browser-session users managing their own automation tokens. They are not accessible with an integration token itself. + +#### `GET /api/integration-tokens` + +Requires a browser session. Lists the current user's active integration tokens for the active workspace. + +Response `200`: + +```json +{ + "integrationTokens": [ + { + "id": "uuid", + "userId": "uuid", + "workspaceId": 1001, + "name": "n8n household sync", + "tokenPrefix": "flpt_1234abcd56", + "scope": "read_write", + "lastUsedAt": "2026-04-14T12:34:56.000Z", + "expiresAt": null, + "revokedAt": null, + "createdAt": "2026-04-14T12:00:00.000Z" + } + ] +} +``` + +#### `POST /api/integration-tokens` + +Requires a browser session. Creates a new integration token for the active workspace and returns the raw token once. + +Request body: + +```json +{ + "name": "n8n household sync", + "scope": "read_write", + "expiresInDays": 90 +} +``` + +Notes: + +- `scope` may be `read_only` or `read_write` +- `expiresInDays` is optional +- the raw token is only returned at creation time + +Response `201`: + +```json +{ + "integrationToken": { + "id": "uuid", + "userId": "uuid", + "workspaceId": 1001, + "name": "n8n household sync", + "tokenPrefix": "flpt_1234abcd56", + "scope": "read_write", + "lastUsedAt": null, + "expiresAt": "2026-07-13T12:00:00.000Z", + "revokedAt": null, + "createdAt": "2026-04-14T12:00:00.000Z" + }, + "token": "flpt_..." +} +``` + +`curl` example: + +```bash +curl -X POST http://localhost:5000/api/integration-tokens \ + -H 'Authorization: Bearer YOUR_SESSION_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "n8n household sync", + "scope": "read_write", + "expiresInDays": 90 + }' +``` + +Use the returned token in your automation tool: + +```http +Authorization: Bearer flpt_... +``` + +#### `DELETE /api/integration-tokens/{tokenId}` + +Requires a browser session. Revokes an integration token owned by the current user in the active workspace. + +Response `204` with no body. + +## Error Summary + +Common status codes used by the API: + +- `200` successful read or update +- `201` resource created +- `202` async or queued success for magic-link requests +- `204` successful delete or logout with no response body +- `400` invalid request payload or expired callback state +- `401` authentication required +- `403` authenticated but not authorized for the action +- `404` resource or provider not found +- `409` uniqueness conflict +- `410` password-based auth endpoints intentionally disabled + +## Source of Truth + +This document reflects the routes currently implemented in: + +- `backend/src/app.ts` + +If the docs and code ever disagree, treat the code as the source of truth. + +## Quick `curl` Workflow + +Basic local-development auth check: + +1. Request a magic link: + +```bash +curl -X POST http://localhost:5000/api/auth/magic-link/request \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "person@example.com", + "name": "Taylor", + "redirectTo": "http://localhost:3000" + }' +``` + +2. Copy the `previewUrl` from the response if local email is not configured. + +3. Open that URL in a browser and capture `auth_token` from the frontend redirect URL. + +4. Use the token: + +```bash +curl http://localhost:5000/api/auth/session \ + -H 'Authorization: Bearer YOUR_SESSION_TOKEN' +``` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 564abd6..bb93ac2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'house type HouseholdBillingPlan = Exclude; type WorkspaceType = 'standard' | 'rescue'; type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; +type IntegrationTokenScope = 'read_only' | 'read_write'; type Bird = { id: string; @@ -89,6 +90,25 @@ type AuthSessionPayload = { providers: AuthProvider[]; }; +type IntegrationTokenSummary = { + id: string; + userId: string; + workspaceId: number; + name: string; + tokenPrefix: string; + scope: IntegrationTokenScope; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; + createdAt: string; +}; + +type IntegrationTokenFormState = { + name: string; + scope: IntegrationTokenScope; + expiresInDays: string; +}; + type BirdFormState = { name: string; tagId: string; @@ -179,7 +199,7 @@ type PhotoDragState = { }; type AppPage = 'overview' | 'flock' | 'settings'; -type SettingsSection = 'collaborators' | 'new-workspace' | 'flock-member' | 'transfer'; +type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api'; const sessionTokenStorageKey = 'flockpal_auth_token'; @@ -220,6 +240,12 @@ const emptyAuthForm: AuthFormState = { email: '', }; +const emptyIntegrationTokenForm: IntegrationTokenFormState = { + name: '', + scope: 'read_write', + expiresInDays: '', +}; + const defaultAuthProviders: AuthProvider[] = [ { providerKey: 'google', displayName: 'Google', enabled: false }, { providerKey: 'microsoft', displayName: 'Microsoft', enabled: false }, @@ -308,6 +334,20 @@ const formatShortDate = (value: string | null) => { }).format(new Date(`${value}T00:00:00`)); }; +const formatDateTime = (value: string | null) => { + if (!value) { + return 'Never'; + } + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)); +}; + const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); @@ -681,6 +721,7 @@ function App() { const [workspace, setWorkspace] = useState(null); const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); + const [integrationTokens, setIntegrationTokens] = useState([]); const [birds, setBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [editingBirdId, setEditingBirdId] = useState(''); @@ -692,6 +733,7 @@ function App() { const [workspaceForm, setWorkspaceForm] = useState(emptyWorkspaceForm); const [workspaceMemberForm, setWorkspaceMemberForm] = useState(emptyWorkspaceMemberForm); const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); + const [integrationTokenForm, setIntegrationTokenForm] = useState(emptyIntegrationTokenForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); const [birdPhotoName, setBirdPhotoName] = useState(''); const [photoCrop, setPhotoCrop] = useState(null); @@ -701,6 +743,9 @@ function App() { const [savingWorkspace, setSavingWorkspace] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); + const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); + const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); + const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false); @@ -960,6 +1005,7 @@ function App() { setAuthSession(session); setAuthProviders(session.providers); setAuthNotice(null); + setNewIntegrationTokenSecret(''); setWorkspace(session.activeWorkspace); setActiveMembership({ ...session.activeMembership, @@ -984,6 +1030,7 @@ function App() { setWorkspace(null); setActiveMembership(null); setWorkspaceMembers([]); + setIntegrationTokens([]); setBirds([]); setWeights([]); setVetVisits([]); @@ -992,6 +1039,8 @@ function App() { setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); setWorkspaceCreateForm(emptyWorkspaceCreateForm); + setIntegrationTokenForm(emptyIntegrationTokenForm); + setNewIntegrationTokenSecret(''); setAuthNotice(null); }; @@ -1065,10 +1114,14 @@ function App() { return; } - const loadBirds = async () => { + const loadWorkspaceData = async () => { try { setLoading(true); - const [birdsResponse, membersResponse] = await Promise.all([apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken)]); + const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([ + apiFetch('/birds', authToken), + apiFetch('/workspace/members', authToken), + apiFetch('/integration-tokens', authToken), + ]); if (!birdsResponse.ok) { if (birdsResponse.status === 401) { @@ -1096,6 +1149,14 @@ function App() { } else { setWorkspaceMembers([]); } + + if (integrationTokensResponse.ok) { + const integrationTokensData = + (await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {}; + setIntegrationTokens(integrationTokensData.integrationTokens ?? []); + } else { + setIntegrationTokens([]); + } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { @@ -1103,7 +1164,7 @@ function App() { } }; - void loadBirds(); + void loadWorkspaceData(); }, [authToken, workspace?.id]); useEffect(() => { @@ -1293,6 +1354,74 @@ function App() { } }; + const handleCreateIntegrationToken = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!authToken) { + return; + } + + setError(''); + setCreatingIntegrationToken(true); + + try { + const response = await apiFetch('/integration-tokens', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: integrationTokenForm.name.trim(), + scope: integrationTokenForm.scope, + expiresInDays: integrationTokenForm.expiresInDays ? Number(integrationTokenForm.expiresInDays) : undefined, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to create integration token.')); + } + + const data = + (await readJsonSafely<{ integrationToken?: IntegrationTokenSummary; token?: string }>(response)) ?? {}; + + if (!data.integrationToken || !data.token) { + throw new Error('Unable to create integration token.'); + } + + setIntegrationTokens((current) => [data.integrationToken!, ...current]); + setIntegrationTokenForm(emptyIntegrationTokenForm); + setNewIntegrationTokenSecret(data.token); + setExpandedSettingsSection('integration-tokens'); + } catch (integrationTokenError) { + setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to create integration token.'); + } finally { + setCreatingIntegrationToken(false); + } + }; + + const handleRevokeIntegrationToken = async (tokenId: string) => { + if (!authToken) { + return; + } + + setError(''); + setRevokingIntegrationTokenId(tokenId); + + try { + const response = await apiFetch(`/integration-tokens/${tokenId}`, authToken, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to revoke integration token.')); + } + + setIntegrationTokens((current) => current.filter((token) => token.id !== tokenId)); + } catch (integrationTokenError) { + setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to revoke integration token.'); + } finally { + setRevokingIntegrationTokenId(''); + } + }; + const handleCreateWorkspace = async (event: React.FormEvent) => { event.preventDefault(); @@ -2785,6 +2914,109 @@ function App() { ) : null} +
+
+
+

Automation

+

Integration tokens

+
+ +
+ {expandedSettingsSection === 'integration-tokens' ? ( + <> +

+ Create a workspace-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears. +

+
+ + + + +
+ + {newIntegrationTokenSecret ? ( +
+ Copy this token now + It will not be shown again after you leave this page or create another token. + event.currentTarget.select()} /> +
+ ) : null} + +
+ {integrationTokens.length ? ( + integrationTokens.map((token) => ( +
+ {token.name} + + {token.tokenPrefix}... • {token.scope === 'read_only' ? 'read only' : 'read and write'} + + + Last used {formatDateTime(token.lastUsedAt)} • Expires {token.expiresAt ? formatDateTime(token.expiresAt) : 'Never'} + + +
+ )) + ) : ( +
+ No integration tokens yet + Create one for n8n, scripts, or other personal automations tied to this workspace. +
+ )} +
+ + ) : null} +
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index 0e62264..2d79abd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -546,6 +546,16 @@ textarea { border-color: rgba(39, 105, 179, 0.24); } +.integration-token-secret { + gap: 0.75rem; +} + +.integration-token-secret input { + width: 100%; + font-family: "IBM Plex Mono", monospace; + font-size: 0.88rem; +} + .bird-list { display: grid; gap: 0.9rem;