added full api
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"test": "tsx --test src/**/*.test.ts",
|
||||
"start": "node dist/app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+297
-974
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
import dotenv from 'dotenv';
|
||||
import pg, { type QueryResult, type QueryResultRow } from 'pg';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
type QueryParams = unknown[] | undefined;
|
||||
|
||||
export class DatabaseClient {
|
||||
private readonly pool: pg.Pool;
|
||||
|
||||
constructor() {
|
||||
const { Pool } = pg;
|
||||
this.pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST ?? 'localhost',
|
||||
port: Number(process.env.POSTGRES_PORT ?? 5432),
|
||||
database: process.env.POSTGRES_DB ?? 'flockpal',
|
||||
user: process.env.POSTGRES_USER ?? 'flockpal',
|
||||
password: process.env.POSTGRES_PASSWORD ?? 'flockpal_dev_password',
|
||||
});
|
||||
}
|
||||
|
||||
query<T extends QueryResultRow>(text: string, params?: QueryParams): Promise<QueryResult<T>> {
|
||||
return this.pool.query<T>(text, params);
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DatabaseClient();
|
||||
@@ -0,0 +1,226 @@
|
||||
import { db, type DatabaseClient } from './client.js';
|
||||
|
||||
export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
await database.query(`
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255),
|
||||
name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL DEFAULT 'My Flock',
|
||||
workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard',
|
||||
billing_email VARCHAR(255),
|
||||
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE workspaces
|
||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic';
|
||||
|
||||
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
|
||||
VALUES (1, 'My Flock', 'standard', 'household_basic')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
invite_email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'staff',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE workspace_members
|
||||
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS invite_email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_members'
|
||||
AND column_name = 'email'
|
||||
) THEN
|
||||
UPDATE workspace_members
|
||||
SET invite_email = COALESCE(invite_email, email)
|
||||
WHERE invite_email IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
UPDATE workspace_members
|
||||
SET invite_email = ''
|
||||
WHERE invite_email IS NULL;
|
||||
|
||||
UPDATE workspace_members
|
||||
SET email = invite_email
|
||||
WHERE email IS NULL;
|
||||
|
||||
ALTER TABLE workspace_members
|
||||
ALTER COLUMN invite_email SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_members_workspace_email
|
||||
ON workspace_members (workspace_id, invite_email);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_members_workspace_user
|
||||
ON workspace_members (workspace_id, user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
scope VARCHAR(16) NOT NULL DEFAULT 'read_write',
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE integration_tokens
|
||||
ADD COLUMN IF NOT EXISTS workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS name VARCHAR(160) NOT NULL DEFAULT 'Integration token',
|
||||
ADD COLUMN IF NOT EXISTS token_prefix VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'read_write',
|
||||
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
|
||||
|
||||
UPDATE integration_tokens
|
||||
SET token_prefix = LEFT(token_hash, 12)
|
||||
WHERE token_prefix IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_tokens_user_workspace
|
||||
ON integration_tokens (user_id, workspace_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_tokens_lookup
|
||||
ON integration_tokens (token_hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_key VARCHAR(32) NOT NULL,
|
||||
provider_subject VARCHAR(255) NOT NULL,
|
||||
provider_email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_accounts_provider_subject
|
||||
ON auth_accounts (provider_key, provider_subject);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_states (
|
||||
id UUID PRIMARY KEY,
|
||||
provider_key VARCHAR(32) NOT NULL,
|
||||
code_verifier VARCHAR(255) NOT NULL,
|
||||
redirect_to TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS magic_link_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(160),
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
redirect_to TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_email
|
||||
ON magic_link_tokens (email, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS birds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
tag_id VARCHAR(80) NOT NULL,
|
||||
species VARCHAR(120) NOT NULL,
|
||||
date_of_birth DATE,
|
||||
gotcha_day DATE,
|
||||
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
photo_data_url TEXT,
|
||||
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE birds
|
||||
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'birds_workspace_fk') THEN
|
||||
ALTER TABLE birds
|
||||
ADD CONSTRAINT birds_workspace_fk
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE birds
|
||||
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
||||
ON birds (workspace_id, tag_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weight_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
||||
recorded_on DATE NOT NULL,
|
||||
notes VARCHAR(280),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (bird_id, recorded_on)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vet_visits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
visited_on DATE NOT NULL,
|
||||
clinic_name VARCHAR(160) NOT NULL,
|
||||
reason VARCHAR(160) NOT NULL,
|
||||
notes VARCHAR(1000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on
|
||||
ON weight_records (bird_id, recorded_on DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on
|
||||
ON vet_visits (bird_id, visited_on DESC);
|
||||
`);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createAuthSession, resolveAuth, resolveIntegrationTokenAuth } from './authRepository.js';
|
||||
import { mockDb } from '../test/mockDb.js';
|
||||
|
||||
test('resolveAuth returns null when session is missing', async () => {
|
||||
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||
|
||||
const result = await resolveAuth('hashed-token', 'raw-token');
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].text, /FROM auth_sessions/);
|
||||
assert.deepEqual(calls[0].params, ['hashed-token']);
|
||||
});
|
||||
|
||||
test('resolveIntegrationTokenAuth maps auth context and records last use', async () => {
|
||||
const { calls } = mockDb(
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
integration_token_id: 'token-1',
|
||||
integration_token_user_id: 'user-1',
|
||||
integration_token_workspace_id: 12,
|
||||
integration_token_name: 'CLI token',
|
||||
integration_token_token_hash: 'hashed-token',
|
||||
integration_token_token_prefix: 'flpt_1234',
|
||||
integration_token_scope: 'read_write',
|
||||
integration_token_last_used_at: null,
|
||||
integration_token_expires_at: '2026-05-01T00:00:00.000Z',
|
||||
integration_token_revoked_at: null,
|
||||
integration_token_created_at: '2026-04-01T00:00:00.000Z',
|
||||
user_id_row: 'user-1',
|
||||
user_email: 'owner@example.com',
|
||||
user_password_hash: null,
|
||||
user_name: 'Owner',
|
||||
user_created_at: '2026-04-01T00:00:00.000Z',
|
||||
workspace_id_row: 12,
|
||||
workspace_name: 'Sanctuary',
|
||||
workspace_workspace_type: 'rescue',
|
||||
workspace_billing_email: 'billing@example.com',
|
||||
workspace_billing_plan: 'rescue_free',
|
||||
workspace_created_at: '2026-04-01T00:00:00.000Z',
|
||||
workspace_updated_at: '2026-04-02T00:00:00.000Z',
|
||||
membership_id_row: 'member-1',
|
||||
membership_workspace_id: 12,
|
||||
membership_user_id: 'user-1',
|
||||
membership_invite_email: 'owner@example.com',
|
||||
membership_name: 'Owner',
|
||||
membership_role: 'owner',
|
||||
membership_accepted_at: '2026-04-01T00:00:00.000Z',
|
||||
membership_created_at: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ rowCount: 1, rows: [] },
|
||||
);
|
||||
|
||||
const result = await resolveIntegrationTokenAuth('hashed-token', 'raw-token');
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result?.authType, 'integration_token');
|
||||
assert.equal(result?.token, 'raw-token');
|
||||
assert.equal(result?.integrationToken?.scope, 'read_write');
|
||||
assert.equal(result?.workspace.id, 12);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].text, /FROM integration_tokens/);
|
||||
assert.match(calls[1].text, /UPDATE integration_tokens/);
|
||||
assert.deepEqual(calls[1].params, ['token-1']);
|
||||
});
|
||||
|
||||
test('createAuthSession persists the provided token hash and expiry', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'session-1',
|
||||
user_id: 'user-1',
|
||||
active_workspace_id: 5,
|
||||
token_hash: 'hashed-token',
|
||||
expires_at: '2026-05-01T00:00:00.000Z',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const session = await createAuthSession('user-1', 5, 'hashed-token', '2026-05-01T00:00:00.000Z');
|
||||
|
||||
assert.equal(session?.id, 'session-1');
|
||||
assert.deepEqual(calls[0].params, ['user-1', 5, 'hashed-token', '2026-05-01T00:00:00.000Z']);
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type {
|
||||
AuthContext,
|
||||
AuthSessionRow,
|
||||
BillingPlan,
|
||||
IntegrationTokenRow,
|
||||
IntegrationTokenScope,
|
||||
MagicLinkTokenRow,
|
||||
OAuthStateRow,
|
||||
ProviderKey,
|
||||
UserRow,
|
||||
WorkspaceMemberRow,
|
||||
WorkspaceRole,
|
||||
WorkspaceRow,
|
||||
WorkspaceType,
|
||||
} from '../types.js';
|
||||
|
||||
const mapSessionAuthRow = (
|
||||
row: AuthSessionRow &
|
||||
UserRow &
|
||||
WorkspaceRow &
|
||||
WorkspaceMemberRow & {
|
||||
session_id: string;
|
||||
session_user_id: string;
|
||||
session_active_workspace_id: number;
|
||||
session_token_hash: string;
|
||||
session_expires_at: string;
|
||||
session_created_at: string;
|
||||
user_id_row: string;
|
||||
user_email: string;
|
||||
user_password_hash: string | null;
|
||||
user_name: string;
|
||||
user_created_at: string;
|
||||
workspace_id_row: number;
|
||||
workspace_name: string;
|
||||
workspace_workspace_type: WorkspaceType;
|
||||
workspace_billing_email: string | null;
|
||||
workspace_billing_plan: BillingPlan;
|
||||
workspace_created_at: string;
|
||||
workspace_updated_at: string;
|
||||
membership_id_row: string;
|
||||
membership_workspace_id: number;
|
||||
membership_user_id: string | null;
|
||||
membership_invite_email: string;
|
||||
membership_name: string;
|
||||
membership_role: WorkspaceRole;
|
||||
membership_accepted_at: string | null;
|
||||
membership_created_at: string;
|
||||
},
|
||||
token: string,
|
||||
): AuthContext => ({
|
||||
user: {
|
||||
id: row.user_id_row,
|
||||
email: row.user_email,
|
||||
password_hash: row.user_password_hash,
|
||||
name: row.user_name,
|
||||
created_at: row.user_created_at,
|
||||
},
|
||||
session: {
|
||||
id: row.session_id,
|
||||
user_id: row.session_user_id,
|
||||
active_workspace_id: row.session_active_workspace_id,
|
||||
token_hash: row.session_token_hash,
|
||||
expires_at: row.session_expires_at,
|
||||
created_at: row.session_created_at,
|
||||
},
|
||||
workspace: {
|
||||
id: row.workspace_id_row,
|
||||
name: row.workspace_name,
|
||||
workspace_type: row.workspace_workspace_type,
|
||||
billing_email: row.workspace_billing_email,
|
||||
billing_plan: row.workspace_billing_plan,
|
||||
created_at: row.workspace_created_at,
|
||||
updated_at: row.workspace_updated_at,
|
||||
},
|
||||
membership: {
|
||||
id: row.membership_id_row,
|
||||
workspace_id: row.membership_workspace_id,
|
||||
user_id: row.membership_user_id,
|
||||
invite_email: row.membership_invite_email,
|
||||
name: row.membership_name,
|
||||
role: row.membership_role,
|
||||
accepted_at: row.membership_accepted_at,
|
||||
created_at: row.membership_created_at,
|
||||
},
|
||||
token,
|
||||
authType: 'session',
|
||||
});
|
||||
|
||||
const mapIntegrationTokenAuthRow = (
|
||||
row: IntegrationTokenRow &
|
||||
UserRow &
|
||||
WorkspaceRow &
|
||||
WorkspaceMemberRow & {
|
||||
integration_token_id: string;
|
||||
integration_token_user_id: string;
|
||||
integration_token_workspace_id: number;
|
||||
integration_token_name: string;
|
||||
integration_token_token_hash: string;
|
||||
integration_token_token_prefix: string;
|
||||
integration_token_scope: IntegrationTokenScope;
|
||||
integration_token_last_used_at: string | null;
|
||||
integration_token_expires_at: string | null;
|
||||
integration_token_revoked_at: string | null;
|
||||
integration_token_created_at: string;
|
||||
user_id_row: string;
|
||||
user_email: string;
|
||||
user_password_hash: string | null;
|
||||
user_name: string;
|
||||
user_created_at: string;
|
||||
workspace_id_row: number;
|
||||
workspace_name: string;
|
||||
workspace_workspace_type: WorkspaceType;
|
||||
workspace_billing_email: string | null;
|
||||
workspace_billing_plan: BillingPlan;
|
||||
workspace_created_at: string;
|
||||
workspace_updated_at: string;
|
||||
membership_id_row: string;
|
||||
membership_workspace_id: number;
|
||||
membership_user_id: string | null;
|
||||
membership_invite_email: string;
|
||||
membership_name: string;
|
||||
membership_role: WorkspaceRole;
|
||||
membership_accepted_at: string | null;
|
||||
membership_created_at: string;
|
||||
},
|
||||
token: string,
|
||||
): AuthContext => ({
|
||||
user: {
|
||||
id: row.user_id_row,
|
||||
email: row.user_email,
|
||||
password_hash: row.user_password_hash,
|
||||
name: row.user_name,
|
||||
created_at: row.user_created_at,
|
||||
},
|
||||
session: {
|
||||
id: row.integration_token_id,
|
||||
user_id: row.integration_token_user_id,
|
||||
active_workspace_id: row.integration_token_workspace_id,
|
||||
token_hash: row.integration_token_token_hash,
|
||||
expires_at: row.integration_token_expires_at ?? '',
|
||||
created_at: row.integration_token_created_at,
|
||||
},
|
||||
workspace: {
|
||||
id: row.workspace_id_row,
|
||||
name: row.workspace_name,
|
||||
workspace_type: row.workspace_workspace_type,
|
||||
billing_email: row.workspace_billing_email,
|
||||
billing_plan: row.workspace_billing_plan,
|
||||
created_at: row.workspace_created_at,
|
||||
updated_at: row.workspace_updated_at,
|
||||
},
|
||||
membership: {
|
||||
id: row.membership_id_row,
|
||||
workspace_id: row.membership_workspace_id,
|
||||
user_id: row.membership_user_id,
|
||||
invite_email: row.membership_invite_email,
|
||||
name: row.membership_name,
|
||||
role: row.membership_role,
|
||||
accepted_at: row.membership_accepted_at,
|
||||
created_at: row.membership_created_at,
|
||||
},
|
||||
token,
|
||||
authType: 'integration_token',
|
||||
integrationToken: {
|
||||
id: row.integration_token_id,
|
||||
user_id: row.integration_token_user_id,
|
||||
workspace_id: row.integration_token_workspace_id,
|
||||
name: row.integration_token_name,
|
||||
token_hash: row.integration_token_token_hash,
|
||||
token_prefix: row.integration_token_token_prefix,
|
||||
scope: row.integration_token_scope,
|
||||
last_used_at: row.integration_token_last_used_at,
|
||||
expires_at: row.integration_token_expires_at,
|
||||
revoked_at: row.integration_token_revoked_at,
|
||||
created_at: row.integration_token_created_at,
|
||||
},
|
||||
});
|
||||
|
||||
export const createAuthSession = async (userId: string, activeWorkspaceId: number, tokenHash: string, expiresAt: string) => {
|
||||
const result = await db.query<AuthSessionRow>(
|
||||
`INSERT INTO auth_sessions (user_id, active_workspace_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, active_workspace_id, token_hash, expires_at::text, created_at`,
|
||||
[userId, activeWorkspaceId, tokenHash, expiresAt],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const deleteAuthSession = async (sessionId: string) => {
|
||||
await db.query('DELETE FROM auth_sessions WHERE id = $1', [sessionId]);
|
||||
};
|
||||
|
||||
export const updateSessionWorkspace = async (sessionId: string, workspaceId: number) => {
|
||||
await db.query(
|
||||
`UPDATE auth_sessions
|
||||
SET active_workspace_id = $2
|
||||
WHERE id = $1`,
|
||||
[sessionId, workspaceId],
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteExpiredMagicLinkTokens = async () => {
|
||||
await db.query(
|
||||
`DELETE FROM magic_link_tokens
|
||||
WHERE expires_at <= CURRENT_TIMESTAMP`,
|
||||
);
|
||||
};
|
||||
|
||||
export const createMagicLinkToken = async (email: string, name: string | null, tokenHash: string, redirectTo: string, expiresAt: string) => {
|
||||
await db.query(
|
||||
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[email, name, tokenHash, redirectTo, expiresAt],
|
||||
);
|
||||
};
|
||||
|
||||
export const consumeMagicLinkToken = async (tokenHash: string) => {
|
||||
const result = await db.query<MagicLinkTokenRow>(
|
||||
`DELETE FROM magic_link_tokens
|
||||
WHERE token_hash = $1
|
||||
AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING id, email, name, token_hash, redirect_to, expires_at::text, created_at`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const findUserByEmail = async (email: string) => {
|
||||
const result = await db.query<UserRow>(
|
||||
`SELECT id, email, password_hash, name, created_at
|
||||
FROM users
|
||||
WHERE email = $1`,
|
||||
[email],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const createUser = async (email: string, name: string) => {
|
||||
const result = await db.query<UserRow>(
|
||||
`INSERT INTO users (email, name)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, password_hash, name, created_at`,
|
||||
[email, name],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const updateUserName = async (userId: string, name: string) => {
|
||||
const result = await db.query<UserRow>(
|
||||
`UPDATE users
|
||||
SET name = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, name, created_at`,
|
||||
[userId, name],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const createOAuthState = async (id: string, providerKey: ProviderKey, codeVerifier: string, redirectTo: string, expiresAt: string) => {
|
||||
await db.query(
|
||||
`INSERT INTO oauth_states (id, provider_key, code_verifier, redirect_to, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, providerKey, codeVerifier, redirectTo, expiresAt],
|
||||
);
|
||||
};
|
||||
|
||||
export const consumeOAuthState = async (stateId: string, providerKey: ProviderKey) => {
|
||||
const result = await db.query<OAuthStateRow>(
|
||||
`DELETE FROM oauth_states
|
||||
WHERE id = $1
|
||||
AND provider_key = $2
|
||||
AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING id, provider_key, code_verifier, redirect_to, expires_at::text`,
|
||||
[stateId, providerKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const findUserByProviderAccount = async (providerKey: ProviderKey, providerSubject: string) => {
|
||||
const result = await db.query<UserRow>(
|
||||
`SELECT users.id, users.email, users.password_hash, users.name, users.created_at
|
||||
FROM auth_accounts
|
||||
INNER JOIN users ON users.id = auth_accounts.user_id
|
||||
WHERE auth_accounts.provider_key = $1
|
||||
AND auth_accounts.provider_subject = $2`,
|
||||
[providerKey, providerSubject],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const linkAuthAccount = async (userId: string, providerKey: ProviderKey, providerSubject: string, providerEmail: string) => {
|
||||
await db.query(
|
||||
`INSERT INTO auth_accounts (user_id, provider_key, provider_subject, provider_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (provider_key, provider_subject) DO NOTHING`,
|
||||
[userId, providerKey, providerSubject, providerEmail],
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveAuth = async (tokenHash: string, token: string) => {
|
||||
const result = await db.query<
|
||||
AuthSessionRow &
|
||||
UserRow &
|
||||
WorkspaceRow &
|
||||
WorkspaceMemberRow & {
|
||||
session_id: string;
|
||||
session_user_id: string;
|
||||
session_active_workspace_id: number;
|
||||
session_token_hash: string;
|
||||
session_expires_at: string;
|
||||
session_created_at: string;
|
||||
user_id_row: string;
|
||||
user_email: string;
|
||||
user_password_hash: string | null;
|
||||
user_name: string;
|
||||
user_created_at: string;
|
||||
workspace_id_row: number;
|
||||
workspace_name: string;
|
||||
workspace_workspace_type: WorkspaceType;
|
||||
workspace_billing_email: string | null;
|
||||
workspace_billing_plan: BillingPlan;
|
||||
workspace_created_at: string;
|
||||
workspace_updated_at: string;
|
||||
membership_id_row: string;
|
||||
membership_workspace_id: number;
|
||||
membership_user_id: string | null;
|
||||
membership_invite_email: string;
|
||||
membership_name: string;
|
||||
membership_role: WorkspaceRole;
|
||||
membership_accepted_at: string | null;
|
||||
membership_created_at: string;
|
||||
}
|
||||
>(
|
||||
`SELECT
|
||||
auth_sessions.id AS session_id,
|
||||
auth_sessions.user_id AS session_user_id,
|
||||
auth_sessions.active_workspace_id AS session_active_workspace_id,
|
||||
auth_sessions.token_hash AS session_token_hash,
|
||||
auth_sessions.expires_at::text AS session_expires_at,
|
||||
auth_sessions.created_at AS session_created_at,
|
||||
users.id AS user_id_row,
|
||||
users.email AS user_email,
|
||||
users.password_hash AS user_password_hash,
|
||||
users.name AS user_name,
|
||||
users.created_at AS user_created_at,
|
||||
workspaces.id AS workspace_id_row,
|
||||
workspaces.name AS workspace_name,
|
||||
workspaces.workspace_type AS workspace_workspace_type,
|
||||
workspaces.billing_email AS workspace_billing_email,
|
||||
workspaces.billing_plan AS workspace_billing_plan,
|
||||
workspaces.created_at AS workspace_created_at,
|
||||
workspaces.updated_at AS workspace_updated_at,
|
||||
workspace_members.id AS membership_id_row,
|
||||
workspace_members.workspace_id AS membership_workspace_id,
|
||||
workspace_members.user_id AS membership_user_id,
|
||||
COALESCE(workspace_members.invite_email, workspace_members.email) AS membership_invite_email,
|
||||
workspace_members.name AS membership_name,
|
||||
workspace_members.role AS membership_role,
|
||||
workspace_members.accepted_at::text AS membership_accepted_at,
|
||||
workspace_members.created_at AS membership_created_at
|
||||
FROM auth_sessions
|
||||
INNER JOIN users ON users.id = auth_sessions.user_id
|
||||
INNER JOIN workspaces ON workspaces.id = auth_sessions.active_workspace_id
|
||||
INNER JOIN workspace_members
|
||||
ON workspace_members.workspace_id = workspaces.id
|
||||
AND workspace_members.user_id = users.id
|
||||
WHERE auth_sessions.token_hash = $1
|
||||
AND auth_sessions.expires_at > CURRENT_TIMESTAMP`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
return result.rows[0] ? mapSessionAuthRow(result.rows[0], token) : null;
|
||||
};
|
||||
|
||||
export const resolveIntegrationTokenAuth = async (tokenHash: string, token: string) => {
|
||||
const result = await db.query<
|
||||
IntegrationTokenRow &
|
||||
UserRow &
|
||||
WorkspaceRow &
|
||||
WorkspaceMemberRow & {
|
||||
integration_token_id: string;
|
||||
integration_token_user_id: string;
|
||||
integration_token_workspace_id: number;
|
||||
integration_token_name: string;
|
||||
integration_token_token_hash: string;
|
||||
integration_token_token_prefix: string;
|
||||
integration_token_scope: IntegrationTokenScope;
|
||||
integration_token_last_used_at: string | null;
|
||||
integration_token_expires_at: string | null;
|
||||
integration_token_revoked_at: string | null;
|
||||
integration_token_created_at: string;
|
||||
user_id_row: string;
|
||||
user_email: string;
|
||||
user_password_hash: string | null;
|
||||
user_name: string;
|
||||
user_created_at: string;
|
||||
workspace_id_row: number;
|
||||
workspace_name: string;
|
||||
workspace_workspace_type: WorkspaceType;
|
||||
workspace_billing_email: string | null;
|
||||
workspace_billing_plan: BillingPlan;
|
||||
workspace_created_at: string;
|
||||
workspace_updated_at: string;
|
||||
membership_id_row: string;
|
||||
membership_workspace_id: number;
|
||||
membership_user_id: string | null;
|
||||
membership_invite_email: string;
|
||||
membership_name: string;
|
||||
membership_role: WorkspaceRole;
|
||||
membership_accepted_at: string | null;
|
||||
membership_created_at: string;
|
||||
}
|
||||
>(
|
||||
`SELECT
|
||||
integration_tokens.id AS integration_token_id,
|
||||
integration_tokens.user_id AS integration_token_user_id,
|
||||
integration_tokens.workspace_id AS integration_token_workspace_id,
|
||||
integration_tokens.name AS integration_token_name,
|
||||
integration_tokens.token_hash AS integration_token_token_hash,
|
||||
integration_tokens.token_prefix AS integration_token_token_prefix,
|
||||
integration_tokens.scope AS integration_token_scope,
|
||||
integration_tokens.last_used_at::text AS integration_token_last_used_at,
|
||||
integration_tokens.expires_at::text AS integration_token_expires_at,
|
||||
integration_tokens.revoked_at::text AS integration_token_revoked_at,
|
||||
integration_tokens.created_at AS integration_token_created_at,
|
||||
users.id AS user_id_row,
|
||||
users.email AS user_email,
|
||||
users.password_hash AS user_password_hash,
|
||||
users.name AS user_name,
|
||||
users.created_at AS user_created_at,
|
||||
workspaces.id AS workspace_id_row,
|
||||
workspaces.name AS workspace_name,
|
||||
workspaces.workspace_type AS workspace_workspace_type,
|
||||
workspaces.billing_email AS workspace_billing_email,
|
||||
workspaces.billing_plan AS workspace_billing_plan,
|
||||
workspaces.created_at AS workspace_created_at,
|
||||
workspaces.updated_at AS workspace_updated_at,
|
||||
workspace_members.id AS membership_id_row,
|
||||
workspace_members.workspace_id AS membership_workspace_id,
|
||||
workspace_members.user_id AS membership_user_id,
|
||||
COALESCE(workspace_members.invite_email, workspace_members.email) AS membership_invite_email,
|
||||
workspace_members.name AS membership_name,
|
||||
workspace_members.role AS membership_role,
|
||||
workspace_members.accepted_at::text AS membership_accepted_at,
|
||||
workspace_members.created_at AS membership_created_at
|
||||
FROM integration_tokens
|
||||
INNER JOIN users ON users.id = integration_tokens.user_id
|
||||
INNER JOIN workspaces ON workspaces.id = integration_tokens.workspace_id
|
||||
INNER JOIN workspace_members
|
||||
ON workspace_members.workspace_id = integration_tokens.workspace_id
|
||||
AND workspace_members.user_id = integration_tokens.user_id
|
||||
WHERE integration_tokens.token_hash = $1
|
||||
AND integration_tokens.revoked_at IS NULL
|
||||
AND (integration_tokens.expires_at IS NULL OR integration_tokens.expires_at > CURRENT_TIMESTAMP)`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE integration_tokens
|
||||
SET last_used_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1`,
|
||||
[result.rows[0].integration_token_id],
|
||||
);
|
||||
|
||||
return mapIntegrationTokenAuthRow(result.rows[0], token);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createBird, getBirdById, listWeightsForBird } from './birdRepository.js';
|
||||
import { mockDb } from '../test/mockDb.js';
|
||||
|
||||
test('getBirdById returns null when the bird does not exist in the workspace', async () => {
|
||||
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||
|
||||
const bird = await getBirdById('bird-1', 10);
|
||||
|
||||
assert.equal(bird, null);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0].params, ['bird-1', 10]);
|
||||
});
|
||||
|
||||
test('createBird returns the inserted bird row', async () => {
|
||||
mockDb({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'bird-1',
|
||||
workspace_id: 10,
|
||||
name: 'Kiwi',
|
||||
tag_id: 'A-1',
|
||||
species: 'Cockatiel',
|
||||
date_of_birth: null,
|
||||
gotcha_day: null,
|
||||
chart_color: '#cb3a35',
|
||||
photo_data_url: null,
|
||||
notify_on_dob: false,
|
||||
notify_on_gotcha_day: false,
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
latest_weight_grams: null,
|
||||
latest_recorded_on: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const bird = await createBird({
|
||||
workspaceId: 10,
|
||||
name: 'Kiwi',
|
||||
tagId: 'A-1',
|
||||
species: 'Cockatiel',
|
||||
dateOfBirth: null,
|
||||
gotchaDay: null,
|
||||
chartColor: '#cb3a35',
|
||||
photoDataUrl: null,
|
||||
notifyOnDob: false,
|
||||
notifyOnGotchaDay: false,
|
||||
});
|
||||
|
||||
assert.equal(bird?.name, 'Kiwi');
|
||||
assert.equal(bird?.workspace_id, 10);
|
||||
});
|
||||
|
||||
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const weights = await listWeightsForBird('bird-1', 10, 30);
|
||||
|
||||
assert.deepEqual(weights, []);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0].params, ['bird-1', 30, 10]);
|
||||
assert.match(calls[0].text, /FROM weight_records/);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type { BirdRow, VetVisitRow, WeightRow } from '../types.js';
|
||||
|
||||
const birdSelectFields = `
|
||||
birds.id,
|
||||
birds.workspace_id,
|
||||
birds.name,
|
||||
birds.tag_id,
|
||||
birds.species,
|
||||
birds.date_of_birth::text,
|
||||
birds.gotcha_day::text,
|
||||
birds.chart_color,
|
||||
birds.photo_data_url,
|
||||
birds.notify_on_dob,
|
||||
birds.notify_on_gotcha_day,
|
||||
birds.created_at,
|
||||
latest.weight_grams AS latest_weight_grams,
|
||||
latest.recorded_on::text AS latest_recorded_on
|
||||
`;
|
||||
|
||||
export const getBirdById = async (birdId: string, workspaceId: number) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`SELECT
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
FROM weight_records
|
||||
WHERE weight_records.bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) latest ON TRUE
|
||||
WHERE birds.id = $1
|
||||
AND birds.workspace_id = $2`,
|
||||
[birdId, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listBirds = async (workspaceId: number) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`SELECT
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
FROM weight_records
|
||||
WHERE weight_records.bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) latest ON TRUE
|
||||
WHERE birds.workspace_id = $1
|
||||
ORDER BY birds.name ASC`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createBird = async ({
|
||||
workspaceId,
|
||||
name,
|
||||
tagId,
|
||||
species,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
}: {
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
tagId: string;
|
||||
species: string;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
chartColor: string;
|
||||
photoDataUrl: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
}) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`INSERT INTO birds (workspace_id, name, tag_id, species, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, workspace_id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
[workspaceId, name, tagId, species, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const updateBird = async ({
|
||||
birdId,
|
||||
workspaceId,
|
||||
name,
|
||||
tagId,
|
||||
species,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
}: {
|
||||
birdId: string;
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
tagId: string;
|
||||
species: string;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
chartColor: string;
|
||||
photoDataUrl: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
}) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`UPDATE birds
|
||||
SET name = $2,
|
||||
tag_id = $3,
|
||||
species = $4,
|
||||
date_of_birth = $5,
|
||||
gotcha_day = $6,
|
||||
chart_color = $7,
|
||||
photo_data_url = $8,
|
||||
notify_on_dob = $9,
|
||||
notify_on_gotcha_day = $10
|
||||
WHERE id = $1
|
||||
AND workspace_id = $11
|
||||
RETURNING id, workspace_id, name, tag_id, species, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
WHERE bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) AS latest_weight_grams,
|
||||
(
|
||||
SELECT recorded_on::text
|
||||
FROM weight_records
|
||||
WHERE bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) AS latest_recorded_on`,
|
||||
[birdId, name, tagId, species, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const deleteBird = async (birdId: string, workspaceId: number) => {
|
||||
const result = await db.query<{ id: string }>(
|
||||
`DELETE FROM birds
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
RETURNING id`,
|
||||
[birdId, workspaceId],
|
||||
);
|
||||
|
||||
return Boolean(result.rowCount);
|
||||
};
|
||||
|
||||
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
||||
const result = await db.query<WeightRow>(
|
||||
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
||||
FROM weight_records
|
||||
WHERE bird_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM birds
|
||||
WHERE birds.id = weight_records.bird_id
|
||||
AND birds.workspace_id = $3
|
||||
)
|
||||
AND recorded_on >= CURRENT_DATE - (($2::int - 1) * INTERVAL '1 day')
|
||||
ORDER BY recorded_on ASC`,
|
||||
[birdId, days, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createWeightForBird = async (birdId: string, weightGrams: number, recordedOn: string, notes: string | null) => {
|
||||
const result = await db.query<WeightRow>(
|
||||
`INSERT INTO weight_records (bird_id, weight_grams, recorded_on, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
|
||||
[birdId, weightGrams, recordedOn, notes],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
||||
const result = await db.query<VetVisitRow>(
|
||||
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
||||
FROM vet_visits
|
||||
WHERE bird_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM birds
|
||||
WHERE birds.id = vet_visits.bird_id
|
||||
AND birds.workspace_id = $2
|
||||
)
|
||||
ORDER BY visited_on DESC, created_at DESC`,
|
||||
[birdId, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createVetVisitForBird = async (birdId: string, visitedOn: string, clinicName: string, reason: string, notes: string | null) => {
|
||||
const result = await db.query<VetVisitRow>(
|
||||
`INSERT INTO vet_visits (bird_id, visited_on, clinic_name, reason, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, bird_id, visited_on::text, clinic_name, reason, notes`,
|
||||
[birdId, visitedOn, clinicName, reason, notes],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type { IntegrationTokenRow, IntegrationTokenScope } from '../types.js';
|
||||
|
||||
export const listIntegrationTokens = async (userId: string, workspaceId: number) => {
|
||||
const result = await db.query<IntegrationTokenRow>(
|
||||
`SELECT id, user_id, workspace_id, name, token_hash, token_prefix, scope, last_used_at::text, expires_at::text, revoked_at::text, created_at
|
||||
FROM integration_tokens
|
||||
WHERE user_id = $1
|
||||
AND workspace_id = $2
|
||||
AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
[userId, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createIntegrationTokenRecord = async ({
|
||||
userId,
|
||||
workspaceId,
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
scope,
|
||||
expiresAt,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenPrefix: string;
|
||||
scope: IntegrationTokenScope;
|
||||
expiresAt: string | null;
|
||||
}) => {
|
||||
const result = await db.query<IntegrationTokenRow>(
|
||||
`INSERT INTO integration_tokens (user_id, workspace_id, name, token_hash, token_prefix, scope, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, user_id, workspace_id, name, token_hash, token_prefix, scope, last_used_at::text, expires_at::text, revoked_at::text, created_at`,
|
||||
[userId, workspaceId, name, tokenHash, tokenPrefix, scope, expiresAt],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const revokeIntegrationToken = async (tokenId: string, userId: string, workspaceId: number) => {
|
||||
const result = await db.query<{ id: string }>(
|
||||
`UPDATE integration_tokens
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
AND user_id = $2
|
||||
AND workspace_id = $3
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id`,
|
||||
[tokenId, userId, workspaceId],
|
||||
);
|
||||
|
||||
return Boolean(result.rowCount);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createWorkspace, ensurePersonalWorkspaceForUser } from './workspaceRepository.js';
|
||||
import { mockDb } from '../test/mockDb.js';
|
||||
import type { UserRow } from '../types.js';
|
||||
|
||||
const user: UserRow = {
|
||||
id: 'user-1',
|
||||
email: 'owner@example.com',
|
||||
password_hash: null,
|
||||
name: 'Owner',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
};
|
||||
|
||||
test('ensurePersonalWorkspaceForUser returns an existing workspace without creating one', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [{ workspace_id: 42 }],
|
||||
});
|
||||
|
||||
const workspaceId = await ensurePersonalWorkspaceForUser(user);
|
||||
|
||||
assert.equal(workspaceId, 42);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].text, /FROM workspace_members/);
|
||||
});
|
||||
|
||||
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
|
||||
const { calls } = mockDb(
|
||||
{ rowCount: 1, rows: [] },
|
||||
{ rowCount: 1, rows: [] },
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 9,
|
||||
name: 'My Rescue',
|
||||
workspace_type: 'rescue',
|
||||
billing_email: 'billing@example.com',
|
||||
billing_plan: 'rescue_free',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
updated_at: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const workspace = await createWorkspace({
|
||||
id: 9,
|
||||
name: 'My Rescue',
|
||||
workspaceType: 'rescue',
|
||||
billingEmail: 'billing@example.com',
|
||||
billingPlan: 'rescue_free',
|
||||
owner: user,
|
||||
});
|
||||
|
||||
assert.equal(workspace?.id, 9);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.match(calls[0].text, /INSERT INTO workspaces/);
|
||||
assert.match(calls[1].text, /INSERT INTO workspace_members/);
|
||||
assert.match(calls[2].text, /SELECT id, name, workspace_type/);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type { BillingPlan, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js';
|
||||
|
||||
export const getNextWorkspaceId = async () => {
|
||||
const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces');
|
||||
return Number(result.rows[0]?.next_id ?? 1);
|
||||
};
|
||||
|
||||
export const getWorkspaceById = async (workspaceId: number) => {
|
||||
const result = await db.query<WorkspaceRow>(
|
||||
`SELECT id, name, workspace_type, billing_email, billing_plan, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE id = $1`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const getMembershipForUser = async (userId: string, workspaceId: number) => {
|
||||
const result = await db.query<WorkspaceMemberRow>(
|
||||
`SELECT id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at
|
||||
FROM workspace_members
|
||||
WHERE workspace_id = $1
|
||||
AND user_id = $2`,
|
||||
[workspaceId, userId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listMembershipsForUser = async (userId: string) => {
|
||||
const result = await db.query<
|
||||
WorkspaceMemberRow & {
|
||||
workspace_name: string;
|
||||
workspace_type: WorkspaceType;
|
||||
billing_email: string | null;
|
||||
billing_plan: BillingPlan;
|
||||
workspace_created_at: string;
|
||||
workspace_updated_at: string;
|
||||
}
|
||||
>(
|
||||
`SELECT
|
||||
workspace_members.id,
|
||||
workspace_members.workspace_id,
|
||||
workspace_members.user_id,
|
||||
COALESCE(workspace_members.invite_email, workspace_members.email) AS invite_email,
|
||||
workspace_members.name,
|
||||
workspace_members.role,
|
||||
workspace_members.accepted_at::text,
|
||||
workspace_members.created_at,
|
||||
workspaces.name AS workspace_name,
|
||||
workspaces.workspace_type,
|
||||
workspaces.billing_email,
|
||||
workspaces.billing_plan,
|
||||
workspaces.created_at AS workspace_created_at,
|
||||
workspaces.updated_at AS workspace_updated_at
|
||||
FROM workspace_members
|
||||
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
|
||||
WHERE workspace_members.user_id = $1
|
||||
ORDER BY workspaces.created_at ASC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
||||
const existing = await db.query<{ workspace_id: number }>(
|
||||
`SELECT workspace_id
|
||||
FROM workspace_members
|
||||
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
|
||||
WHERE workspace_members.user_id = $1
|
||||
AND workspaces.workspace_type = 'standard'
|
||||
ORDER BY workspaces.created_at ASC
|
||||
LIMIT 1`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
if (existing.rowCount) {
|
||||
return Number(existing.rows[0].workspace_id);
|
||||
}
|
||||
|
||||
const unclaimed = await db.query<{ workspace_id: number }>(
|
||||
`SELECT workspaces.id AS workspace_id
|
||||
FROM workspaces
|
||||
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
||||
WHERE workspaces.id = 1
|
||||
GROUP BY workspaces.id
|
||||
HAVING COUNT(workspace_members.id) = 0
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
|
||||
|
||||
if (!unclaimed.rowCount) {
|
||||
await db.query(
|
||||
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email)
|
||||
VALUES ($1, $2, 'standard', 'household_basic', $3)`,
|
||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
`UPDATE workspaces
|
||||
SET name = $2,
|
||||
workspace_type = 'standard',
|
||||
billing_plan = 'household_basic',
|
||||
billing_email = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1`,
|
||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||
);
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
||||
VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (workspace_id, invite_email) DO UPDATE
|
||||
SET user_id = EXCLUDED.user_id,
|
||||
email = EXCLUDED.email,
|
||||
name = EXCLUDED.name,
|
||||
role = 'owner',
|
||||
accepted_at = CURRENT_TIMESTAMP`,
|
||||
[workspaceId, user.id, user.email, user.name],
|
||||
);
|
||||
|
||||
return workspaceId;
|
||||
};
|
||||
|
||||
export const claimWorkspaceInvites = async (user: UserRow) => {
|
||||
await db.query(
|
||||
`UPDATE workspace_members
|
||||
SET user_id = $1,
|
||||
accepted_at = CURRENT_TIMESTAMP
|
||||
WHERE LOWER(COALESCE(invite_email, email)) = LOWER($2)
|
||||
AND user_id IS NULL`,
|
||||
[user.id, user.email],
|
||||
);
|
||||
};
|
||||
|
||||
export const createWorkspace = async ({
|
||||
id,
|
||||
name,
|
||||
workspaceType,
|
||||
billingEmail,
|
||||
billingPlan,
|
||||
owner,
|
||||
}: {
|
||||
id: number;
|
||||
name: string;
|
||||
workspaceType: WorkspaceType;
|
||||
billingEmail: string | null;
|
||||
billingPlan: BillingPlan;
|
||||
owner: UserRow;
|
||||
}) => {
|
||||
await db.query(
|
||||
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, name, workspaceType, billingEmail, billingPlan],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
||||
VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP)`,
|
||||
[id, owner.id, owner.email, owner.name],
|
||||
);
|
||||
|
||||
return getWorkspaceById(id);
|
||||
};
|
||||
|
||||
export const updateWorkspace = async ({
|
||||
workspaceId,
|
||||
name,
|
||||
workspaceType,
|
||||
billingEmail,
|
||||
billingPlan,
|
||||
}: {
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
workspaceType: WorkspaceType;
|
||||
billingEmail: string | null;
|
||||
billingPlan: BillingPlan;
|
||||
}) => {
|
||||
const result = await db.query<WorkspaceRow>(
|
||||
`UPDATE workspaces
|
||||
SET name = $2,
|
||||
workspace_type = $3,
|
||||
billing_email = $4,
|
||||
billing_plan = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING id, name, workspace_type, billing_email, billing_plan, created_at, updated_at`,
|
||||
[workspaceId, name, workspaceType, billingEmail, billingPlan],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listWorkspaceMembers = async (workspaceId: number) => {
|
||||
const result = await db.query<WorkspaceMemberRow>(
|
||||
`SELECT id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at
|
||||
FROM workspace_members
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const upsertWorkspaceMember = async ({
|
||||
workspaceId,
|
||||
inviteEmail,
|
||||
name,
|
||||
role,
|
||||
existingUser,
|
||||
}: {
|
||||
workspaceId: number;
|
||||
inviteEmail: string;
|
||||
name: string;
|
||||
role: WorkspaceMemberRow['role'];
|
||||
existingUser: UserRow | null;
|
||||
}) => {
|
||||
const result = await db.query<WorkspaceMemberRow>(
|
||||
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
||||
VALUES ($1, $2, $3, $3, $4, $5, $6)
|
||||
ON CONFLICT (workspace_id, invite_email) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
role = EXCLUDED.role,
|
||||
email = EXCLUDED.email,
|
||||
user_id = COALESCE(workspace_members.user_id, EXCLUDED.user_id),
|
||||
accepted_at = COALESCE(workspace_members.accepted_at, EXCLUDED.accepted_at)
|
||||
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
|
||||
[workspaceId, existingUser?.id ?? null, inviteEmail, name, role, existingUser ? new Date().toISOString() : null],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => {
|
||||
const result = await db.query<{ id: string }>(
|
||||
`DELETE FROM workspace_members
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND role <> 'owner'
|
||||
RETURNING id`,
|
||||
[memberId, workspaceId],
|
||||
);
|
||||
|
||||
return Boolean(result.rowCount);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach } from 'node:test';
|
||||
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
type QueryCall = {
|
||||
text: string;
|
||||
params: unknown[] | undefined;
|
||||
};
|
||||
|
||||
type MockQueryResult = {
|
||||
rowCount?: number;
|
||||
rows?: unknown[];
|
||||
};
|
||||
|
||||
type MockHandler = MockQueryResult | ((call: QueryCall) => MockQueryResult | Promise<MockQueryResult>);
|
||||
|
||||
const originalQuery = db.query.bind(db);
|
||||
|
||||
export const mockDb = (...handlers: MockHandler[]) => {
|
||||
const calls: QueryCall[] = [];
|
||||
const queue = [...handlers];
|
||||
|
||||
const mockedQuery = async (text: string, params?: unknown[]) => {
|
||||
const call = { text, params };
|
||||
calls.push(call);
|
||||
|
||||
const next = queue.shift();
|
||||
|
||||
if (!next) {
|
||||
throw new Error(`Unexpected query: ${text}`);
|
||||
}
|
||||
|
||||
const result = typeof next === 'function' ? await next(call) : next;
|
||||
|
||||
return {
|
||||
rowCount: result.rowCount ?? result.rows?.length ?? 0,
|
||||
rows: result.rows ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
(db as typeof db & {
|
||||
query: typeof db.query;
|
||||
}).query = mockedQuery as typeof db.query;
|
||||
|
||||
return { calls };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
(db as typeof db & {
|
||||
query: typeof originalQuery;
|
||||
}).query = originalQuery;
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
export type WorkspaceType = 'standard' | 'rescue';
|
||||
export type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
|
||||
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string | null;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
workspace_type: WorkspaceType;
|
||||
billing_email: string | null;
|
||||
billing_plan: BillingPlan;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMemberRow = {
|
||||
id: string;
|
||||
workspace_id: number;
|
||||
user_id: string | null;
|
||||
invite_email: string;
|
||||
name: string;
|
||||
role: WorkspaceRole;
|
||||
accepted_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuthSessionRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
active_workspace_id: number;
|
||||
token_hash: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuthAccountRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
provider_key: ProviderKey;
|
||||
provider_subject: string;
|
||||
provider_email: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OAuthStateRow = {
|
||||
id: string;
|
||||
provider_key: ProviderKey;
|
||||
code_verifier: string;
|
||||
redirect_to: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type MagicLinkTokenRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
token_hash: string;
|
||||
redirect_to: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type IntegrationTokenRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
workspace_id: number;
|
||||
name: string;
|
||||
token_hash: string;
|
||||
token_prefix: string;
|
||||
scope: IntegrationTokenScope;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type BirdRow = {
|
||||
id: string;
|
||||
workspace_id: number;
|
||||
name: string;
|
||||
tag_id: string;
|
||||
species: string;
|
||||
date_of_birth: string | null;
|
||||
gotcha_day: string | null;
|
||||
chart_color: string;
|
||||
photo_data_url: string | null;
|
||||
notify_on_dob: boolean;
|
||||
notify_on_gotcha_day: boolean;
|
||||
created_at: string;
|
||||
latest_weight_grams: string | null;
|
||||
latest_recorded_on: string | null;
|
||||
};
|
||||
|
||||
export type WeightRow = {
|
||||
id: string;
|
||||
bird_id: string;
|
||||
weight_grams: string;
|
||||
recorded_on: string;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type VetVisitRow = {
|
||||
id: string;
|
||||
bird_id: string;
|
||||
visited_on: string;
|
||||
clinic_name: string;
|
||||
reason: string;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type AuthContext = {
|
||||
user: UserRow;
|
||||
session: AuthSessionRow;
|
||||
workspace: WorkspaceRow;
|
||||
membership: WorkspaceMemberRow;
|
||||
token: string;
|
||||
authType: 'session' | 'integration_token';
|
||||
integrationToken?: IntegrationTokenRow;
|
||||
};
|
||||
Reference in New Issue
Block a user