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', billing_interval VARCHAR(16) NOT NULL DEFAULT 'monthly', subscription_status VARCHAR(32) NOT NULL DEFAULT 'none', stripe_customer_id VARCHAR(255), stripe_subscription_id VARCHAR(255), rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required', 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', ADD COLUMN IF NOT EXISTS billing_interval VARCHAR(16) NOT NULL DEFAULT 'monthly', ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(32) NOT NULL DEFAULT 'none', ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255), ADD COLUMN IF NOT EXISTS rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required'; CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_stripe_subscription_id ON workspaces (stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_workspaces_stripe_customer_id ON workspaces (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_workspaces_rescue_status ON workspaces (workspace_type, rescue_verification_status, created_at DESC); UPDATE workspaces SET subscription_status = 'none' WHERE workspace_type = 'standard' AND stripe_subscription_id IS NULL AND subscription_status = 'active'; UPDATE workspaces SET rescue_verification_status = 'pending' WHERE workspace_type = 'rescue' AND rescue_verification_status = 'not_required'; 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 'caregiver', 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; UPDATE workspace_members SET role = CASE WHEN role = 'manager' THEN 'assistant' WHEN role = 'staff' THEN 'caregiver' ELSE role END WHERE role IN ('manager', 'staff'); 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 INDEX IF NOT EXISTS idx_workspace_members_user_accepted ON workspace_members (user_id, accepted_at, workspace_id) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_workspace_members_owner_email ON workspace_members (LOWER(COALESCE(invite_email, email)), workspace_id) WHERE role = 'owner' AND accepted_at 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 INDEX IF NOT EXISTS idx_auth_sessions_created_user ON auth_sessions (created_at DESC, user_id); 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), species VARCHAR(120) NOT NULL, motivators VARCHAR(1000), demotivators VARCHAR(1000), favorite_snack VARCHAR(160), gender VARCHAR(16) NOT NULL DEFAULT 'unknown', date_of_birth DATE, gotcha_day DATE, chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', photo_data_url TEXT, photo_object_key TEXT, photo_content_type VARCHAR(80), photo_updated_at TIMESTAMPTZ, notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE, public_profile_code VARCHAR(32), public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE, memorialized_at TIMESTAMPTZ, memorialized_on DATE, memorial_note VARCHAR(1000), notify_on_memorial_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 motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown', 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 photo_object_key TEXT, ADD COLUMN IF NOT EXISTS photo_content_type VARCHAR(80), ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ, 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, ADD COLUMN IF NOT EXISTS public_profile_code VARCHAR(32), ADD COLUMN IF NOT EXISTS public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS memorialized_on DATE, ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000), ADD COLUMN IF NOT EXISTS notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE birds ALTER COLUMN tag_id DROP NOT NULL; UPDATE birds SET tag_id = NULL WHERE tag_id IS NOT NULL AND LOWER(BTRIM(tag_id)) IN ('unknown', 'not recorded', 'n/a', 'na', 'none'); 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 $$; DELETE FROM workspaces WHERE id = 1 AND name = 'My Flock' AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_members.workspace_id = workspaces.id) AND NOT EXISTS (SELECT 1 FROM birds WHERE birds.workspace_id = workspaces.id); ALTER TABLE birds DROP CONSTRAINT IF EXISTS birds_tag_id_key; DROP INDEX IF EXISTS idx_birds_workspace_tag_id; CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id ON birds (workspace_id, LOWER(tag_id)) WHERE tag_id IS NOT NULL AND BTRIM(tag_id) <> '' AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none'); CREATE INDEX IF NOT EXISTS idx_birds_workspace_active_name ON birds (workspace_id, name) WHERE memorialized_at IS NULL; CREATE INDEX IF NOT EXISTS idx_birds_workspace_memorialized ON birds (workspace_id, memorialized_on DESC, name) WHERE memorialized_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_birds_tag_lookup_active ON birds (LOWER(tag_id), created_at) WHERE tag_id IS NOT NULL AND BTRIM(tag_id) <> '' AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none') AND memorialized_at IS NULL; CREATE INDEX IF NOT EXISTS idx_birds_photo_object_key ON birds (photo_object_key) WHERE photo_object_key IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_public_profile_code ON birds (public_profile_code) WHERE public_profile_code IS NOT NULL; CREATE TABLE IF NOT EXISTS pending_bird_transfers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, destination_owner_email VARCHAR(255) NOT NULL, requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, completed_at TIMESTAMPTZ, completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL, last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); ALTER TABLE pending_bird_transfers ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS last_error TEXT; CREATE INDEX IF NOT EXISTS idx_pending_bird_transfers_destination_email ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC) WHERE completed_at IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird ON pending_bird_transfers (bird_id) WHERE completed_at IS NULL; CREATE TABLE IF NOT EXISTS flock_notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, bird_id UUID REFERENCES birds(id) ON DELETE SET NULL, title VARCHAR(160) NOT NULL, body TEXT NOT NULL, created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated ON flock_notes (workspace_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated ON flock_notes (bird_id, updated_at DESC) WHERE bird_id IS NOT NULL; CREATE TABLE IF NOT EXISTS audit_log_entries ( 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 SET NULL, actor_name VARCHAR(160), actor_email VARCHAR(255), action VARCHAR(80) NOT NULL, entity_type VARCHAR(80) NOT NULL, entity_id VARCHAR(120), entity_name VARCHAR(255), details JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created ON audit_log_entries (workspace_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC); 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 TABLE IF NOT EXISTS medications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, name VARCHAR(160) NOT NULL, dosage VARCHAR(160) NOT NULL, frequency VARCHAR(160) NOT NULL, dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb, route VARCHAR(80), start_date DATE NOT NULL, end_date DATE, notes VARCHAR(1000), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (end_date IS NULL OR end_date >= start_date) ); ALTER TABLE medications ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb; CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, reminder_type VARCHAR(24) NOT NULL CHECK (reminder_type IN ('hatch_day', 'gotcha_day', 'memorial_day')), reminder_year INTEGER NOT NULL, delivered_on DATE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (bird_id, reminder_type, reminder_year) ); ALTER TABLE bird_milestone_reminder_deliveries DROP CONSTRAINT IF EXISTS bird_milestone_reminder_deliveries_reminder_type_check; ALTER TABLE bird_milestone_reminder_deliveries ADD CONSTRAINT bird_milestone_reminder_deliveries_reminder_type_check CHECK (reminder_type IN ('hatch_day', 'gotcha_day', 'memorial_day')); CREATE TABLE IF NOT EXISTS medication_administrations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, administered_on DATE NOT NULL, administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1', status VARCHAR(20) NOT NULL CHECK (status IN ('administered', 'missed')), notes VARCHAR(500), created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); ALTER TABLE medication_administrations ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1'; ALTER TABLE medication_administrations DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key; CREATE UNIQUE INDEX IF NOT EXISTS idx_medication_administrations_unique_slot ON medication_administrations (medication_id, administered_on, administration_slot); 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); CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date ON medications (bird_id, start_date DESC); CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC); CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on ON medication_administrations (bird_id, administered_on DESC); CREATE INDEX IF NOT EXISTS idx_medication_administrations_medication_date ON medication_administrations (medication_id, administered_on DESC, created_at DESC); DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'birds' AND column_name = 'is_female' ) THEN UPDATE birds SET gender = CASE WHEN is_female IS TRUE THEN 'female' WHEN is_female IS FALSE THEN 'male' ELSE gender END WHERE gender = 'unknown'; END IF; END $$; `); };