added full api
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+297
-974
File diff suppressed because it is too large
Load Diff
@@ -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<T extends QueryResultRow>(text: string, params?: QueryParams): Promise<QueryResult<T>> {
|
||||
return this.pool.query<T>(text, params);
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DatabaseClient();
|
||||
@@ -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);
|
||||
`);
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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<AuthSessionRow>(
|
||||
`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<MagicLinkTokenRow>(
|
||||
`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<UserRow>(
|
||||
`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<UserRow>(
|
||||
`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<UserRow>(
|
||||
`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<OAuthStateRow>(
|
||||
`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<UserRow>(
|
||||
`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);
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<BirdRow>(
|
||||
`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<BirdRow>(
|
||||
`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<BirdRow>(
|
||||
`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<BirdRow>(
|
||||
`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<WeightRow>(
|
||||
`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<WeightRow>(
|
||||
`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<VetVisitRow>(
|
||||
`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<VetVisitRow>(
|
||||
`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;
|
||||
};
|
||||
@@ -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<IntegrationTokenRow>(
|
||||
`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<IntegrationTokenRow>(
|
||||
`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);
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<WorkspaceRow>(
|
||||
`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<WorkspaceMemberRow>(
|
||||
`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<WorkspaceRow>(
|
||||
`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<WorkspaceMemberRow>(
|
||||
`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<WorkspaceMemberRow>(
|
||||
`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);
|
||||
};
|
||||
@@ -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<MockQueryResult>);
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 <auth_token>
|
||||
```
|
||||
|
||||
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'
|
||||
```
|
||||
+236
-4
@@ -6,6 +6,7 @@ type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'house
|
||||
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||
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<Workspace | null>(null);
|
||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
||||
const [birds, setBirds] = useState<Bird[]>([]);
|
||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||
@@ -692,6 +733,7 @@ function App() {
|
||||
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(emptyWorkspaceForm);
|
||||
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
|
||||
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
||||
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
||||
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
||||
const [birdPhotoName, setBirdPhotoName] = useState('');
|
||||
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(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<number | null>(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<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -2785,6 +2914,109 @@ function App() {
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Automation</p>
|
||||
<h2>Integration tokens</h2>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
setExpandedSettingsSection((current) => (current === 'integration-tokens' ? null : 'integration-tokens'))
|
||||
}
|
||||
type="button"
|
||||
aria-expanded={expandedSettingsSection === 'integration-tokens'}
|
||||
>
|
||||
{expandedSettingsSection === 'integration-tokens' ? 'Close' : 'Open'}
|
||||
</button>
|
||||
</div>
|
||||
{expandedSettingsSection === 'integration-tokens' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
|
||||
<label>
|
||||
Token name
|
||||
<input
|
||||
value={integrationTokenForm.name}
|
||||
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, name: event.target.value })}
|
||||
placeholder="n8n household sync"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Access level
|
||||
<select
|
||||
value={integrationTokenForm.scope}
|
||||
onChange={(event) =>
|
||||
setIntegrationTokenForm({
|
||||
...integrationTokenForm,
|
||||
scope: event.target.value as IntegrationTokenFormState['scope'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="read_write">Read and write</option>
|
||||
<option value="read_only">Read only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Expire after days
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
value={integrationTokenForm.expiresInDays}
|
||||
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, expiresInDays: event.target.value })}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={creatingIntegrationToken}>
|
||||
{creatingIntegrationToken ? 'Creating token...' : 'Create integration token'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{newIntegrationTokenSecret ? (
|
||||
<article className="summary-card integration-token-secret">
|
||||
<strong>Copy this token now</strong>
|
||||
<span>It will not be shown again after you leave this page or create another token.</span>
|
||||
<input readOnly value={newIntegrationTokenSecret} onFocus={(event) => event.currentTarget.select()} />
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<div className="recent-list">
|
||||
{integrationTokens.length ? (
|
||||
integrationTokens.map((token) => (
|
||||
<article key={token.id} className="vet-visit-card">
|
||||
<strong>{token.name}</strong>
|
||||
<span>
|
||||
{token.tokenPrefix}... • {token.scope === 'read_only' ? 'read only' : 'read and write'}
|
||||
</span>
|
||||
<small>
|
||||
Last used {formatDateTime(token.lastUsedAt)} • Expires {token.expiresAt ? formatDateTime(token.expiresAt) : 'Never'}
|
||||
</small>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handleRevokeIntegrationToken(token.id)}
|
||||
type="button"
|
||||
disabled={revokingIntegrationTokenId === token.id}
|
||||
>
|
||||
{revokingIntegrationTokenId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article className="vet-visit-card empty-card">
|
||||
<strong>No integration tokens yet</strong>
|
||||
<small>Create one for n8n, scripts, or other personal automations tied to this workspace.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user