added full api

This commit is contained in:
blaisadmin
2026-04-14 22:41:17 -04:00
parent e0ab66d21a
commit 37c8265320
17 changed files with 3146 additions and 978 deletions
+2
View File
@@ -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
+1
View File
@@ -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": {
+288 -965
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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();
+226
View File
@@ -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']);
});
+478
View File
@@ -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/);
});
+222
View File
@@ -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);
};
+52
View File
@@ -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;
});
+128
View File
@@ -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;
};
+929
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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;