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
+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": {
+297 -974
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;
};