-- ========================================== -- Chat Switchboard — v0.9.0 Consolidated Schema -- ========================================== -- Clean-slate schema. Replaces all 001–021 migrations. -- Drop DB and re-create before applying. -- -- Design principles: -- • Explicit scope enums over nullable column tri-states -- • Personas as trust boundaries with extensible grants -- • Secure by default (models hidden until admin enables) -- • Only tables with active handlers — no placeholder tables -- ========================================== -- ── Extensions ────────────────────────────── CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() -- ── Utility: auto-update updated_at ───────── CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- ========================================= -- 1. USERS -- ========================================= CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(50) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name VARCHAR(100), avatar_url TEXT, role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin', 'moderator')), is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), last_login_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); DROP TRIGGER IF EXISTS users_updated_at ON users; CREATE TRIGGER users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -- ========================================= -- 2. AUTH -- ========================================= CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL; -- ========================================= -- 3. TEAMS -- ========================================= CREATE TABLE IF NOT EXISTS teams ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL UNIQUE, description TEXT DEFAULT '', created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true; DROP TRIGGER IF EXISTS teams_updated_at ON teams; CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}'; CREATE TABLE IF NOT EXISTS team_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')), joined_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(team_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id); CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id); -- ========================================= -- 4. PROVIDER CONFIGS (replaces api_configs) -- ========================================= -- Explicit scope enum replaces nullable column tri-state. -- scope='global': admin-managed, visible to all users (owner_id IS NULL) -- scope='team': team admin-managed (owner_id = teams.id) -- scope='personal': user's own keys (owner_id = users.id) CREATE TABLE IF NOT EXISTS provider_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), scope VARCHAR(10) NOT NULL CHECK (scope IN ('global', 'team', 'personal')), owner_id UUID, name VARCHAR(100) NOT NULL, provider VARCHAR(50) NOT NULL, endpoint TEXT NOT NULL, api_key_enc TEXT, model_default VARCHAR(100), config JSONB DEFAULT '{}'::jsonb, headers JSONB DEFAULT '{}'::jsonb, settings JSONB DEFAULT '{}'::jsonb, is_active BOOLEAN DEFAULT true, is_private BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope); CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id) WHERE owner_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active) WHERE is_active = true; DROP TRIGGER IF EXISTS provider_configs_updated_at ON provider_configs; CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK'; COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope'; COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)'; COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)'; COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)'; -- ========================================= -- 5. MODEL CATALOG (replaces model_configs) -- ========================================= -- Intrinsic capabilities for models the system knows about. -- Hidden by default — admin must enable. CREATE TABLE IF NOT EXISTS model_catalog ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE, model_id TEXT NOT NULL, display_name TEXT, capabilities JSONB NOT NULL DEFAULT '{}', pricing JSONB, visibility VARCHAR(10) DEFAULT 'disabled' CHECK (visibility IN ('enabled', 'disabled', 'team')), last_synced_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(provider_config_id, model_id) ); CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id); CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled'; -- Fix CHECK constraint and default for existing databases (idempotent) DO $$ BEGIN ALTER TABLE model_catalog DROP CONSTRAINT IF EXISTS model_catalog_visibility_check; -- Remap old values before adding new constraint UPDATE model_catalog SET visibility = 'enabled' WHERE visibility = 'visible'; UPDATE model_catalog SET visibility = 'disabled' WHERE visibility = 'hidden'; ALTER TABLE model_catalog ADD CONSTRAINT model_catalog_visibility_check CHECK (visibility IN ('enabled', 'disabled', 'team')); ALTER TABLE model_catalog ALTER COLUMN visibility SET DEFAULT 'disabled'; EXCEPTION WHEN OTHERS THEN NULL; END $$; DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog; CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN model_catalog.visibility IS 'hidden by default — admin must enable for global models'; COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}'; -- ========================================= -- 6. PERSONAS (replaces model_presets) -- ========================================= CREATE TABLE IF NOT EXISTS personas ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, description TEXT DEFAULT '', icon VARCHAR(10) DEFAULT '', avatar TEXT DEFAULT '', -- Base model binding base_model_id TEXT NOT NULL, provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, -- Behavioral configuration system_prompt TEXT DEFAULT '', temperature FLOAT, max_tokens INT, thinking_budget INT, top_p FLOAT, -- Scope & ownership scope VARCHAR(10) NOT NULL CHECK (scope IN ('global', 'team', 'personal')), owner_id UUID, created_by UUID NOT NULL REFERENCES users(id), -- State is_active BOOLEAN DEFAULT true, is_shared BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope); CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true; DROP TRIGGER IF EXISTS personas_updated_at ON personas; CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only'; COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal'; COMMENT ON COLUMN personas.is_shared IS 'Personal Personas shared with others (read-only)'; -- ========================================= -- 7. PERSONA GRANTS (extensible resource binding) -- ========================================= CREATE TABLE IF NOT EXISTS persona_grants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE, grant_type VARCHAR(30) NOT NULL, grant_ref TEXT NOT NULL, config JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(persona_id, grant_type, grant_ref) ); CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id); CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type); COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base (future), api_endpoint (future)'; COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.'; COMMENT ON COLUMN persona_grants.config IS 'Type-specific config, e.g. {"read_only": true} for KB grants'; -- ========================================= -- 8. PLATFORM POLICIES (replaces scattered global_settings checks) -- ========================================= CREATE TABLE IF NOT EXISTS platform_policies ( key VARCHAR(50) PRIMARY KEY, value TEXT NOT NULL, updated_by UUID REFERENCES users(id), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Secure by default INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'false'), ('allow_user_personas', 'false'), ('allow_raw_model_access', 'true'), ('allow_registration', 'true'), ('default_user_active', 'false'), ('allow_team_providers', 'true') ON CONFLICT (key) DO NOTHING; COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platform behavior'; -- ========================================= -- 9. GLOBAL SETTINGS (non-policy config) -- ========================================= -- Retained for banner config, site branding, etc. -- Policy-like keys move to platform_policies. CREATE TABLE IF NOT EXISTS global_settings ( key VARCHAR(100) PRIMARY KEY, value JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ DEFAULT NOW(), updated_by UUID REFERENCES users(id) ); -- Seed defaults INSERT INTO global_settings (key, value) VALUES ('registration', '{"enabled": true}'::jsonb), ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb), ('banner', '{ "enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff" }'::jsonb), ('banner_presets', '{ "development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" }, "testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" }, "staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" }, "production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" }, "training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" }, "demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" } }'::jsonb) ON CONFLICT (key) DO NOTHING; -- ========================================= -- 10. USER MODEL SETTINGS (replaces user_model_preferences) -- ========================================= CREATE TABLE IF NOT EXISTS user_model_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, model_id TEXT NOT NULL, hidden BOOLEAN DEFAULT false, preferred_temperature FLOAT, preferred_max_tokens INT, sort_order INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, model_id) ); CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id); DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings; CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -- ========================================= -- 11. CHANNELS (unified chats) -- ========================================= CREATE TABLE IF NOT EXISTS channels ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, description TEXT, type VARCHAR(20) DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'channel')), model VARCHAR(100), system_prompt TEXT, provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, is_archived BOOLEAN DEFAULT false, is_pinned BOOLEAN DEFAULT false, folder_id UUID, -- FK added after folders table folder TEXT, -- backward compat: simple text folder name team_id UUID REFERENCES teams(id) ON DELETE SET NULL, settings JSONB DEFAULT '{}'::jsonb, tags TEXT[], created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type); CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags); DROP TRIGGER IF EXISTS channels_updated_at ON channels; CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent'; -- ========================================= -- 12. MESSAGES (with tree/forking support) -- ========================================= CREATE TABLE IF NOT EXISTS messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant', 'system', 'tool')), content TEXT NOT NULL, model VARCHAR(100), tokens_used INTEGER, tool_calls JSONB, metadata JSONB DEFAULT '{}'::jsonb, parent_id UUID REFERENCES messages(id) ON DELETE SET NULL, sibling_index INTEGER DEFAULT 0, participant_type VARCHAR(10) DEFAULT 'user', participant_id VARCHAR(255), deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id); CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index) WHERE deleted_at IS NULL; COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking'; COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)'; COMMENT ON COLUMN messages.participant_type IS 'user or model'; COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string'; -- ========================================= -- 13. CHANNEL MEMBERS & MODELS -- ========================================= CREATE TABLE IF NOT EXISTS channel_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(20) DEFAULT 'member', joined_at TIMESTAMPTZ DEFAULT NOW(), last_read_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(channel_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id); CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id); CREATE TABLE IF NOT EXISTS channel_models ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, model_id VARCHAR(255) NOT NULL, provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, display_name VARCHAR(100), system_prompt TEXT, settings JSONB DEFAULT '{}'::jsonb, is_default BOOLEAN DEFAULT false, added_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(channel_id, model_id) ); CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id); -- ========================================= -- 14. CHANNEL CURSORS (forking navigation) -- ========================================= CREATE TABLE IF NOT EXISTS channel_cursors ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL, updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(channel_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id); CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id); -- ========================================= -- 15. FOLDERS & PROJECTS -- ========================================= CREATE TABLE IF NOT EXISTS folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(200) NOT NULL, parent_id UUID REFERENCES folders(id) ON DELETE CASCADE, sort_order INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, name, parent_id) ); CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id); CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id); DROP TRIGGER IF EXISTS folders_updated_at ON folders; CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -- Now add the FK from channels DO $$ BEGIN ALTER TABLE channels ADD CONSTRAINT fk_channels_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL; EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL; CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(200) NOT NULL, description TEXT, color VARCHAR(7), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_projects_user ON projects(user_id); DROP TRIGGER IF EXISTS projects_updated_at ON projects; CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TABLE IF NOT EXISTS project_channels ( project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, added_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (project_id, channel_id) ); CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id); -- ========================================= -- 16. NOTES -- ========================================= CREATE TABLE IF NOT EXISTS notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, content TEXT DEFAULT '', folder_path TEXT DEFAULT '/', tags TEXT[] DEFAULT '{}', metadata JSONB DEFAULT '{}', source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL, team_id UUID REFERENCES teams(id) ON DELETE SET NULL, search_vector TSVECTOR, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id); CREATE INDEX IF NOT EXISTS idx_notes_search ON notes USING GIN(search_vector); CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path); CREATE INDEX IF NOT EXISTS idx_notes_tags ON notes USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL; CREATE OR REPLACE FUNCTION notes_search_update_fn() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B'); NEW.updated_at := NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS notes_search_update ON notes; CREATE TRIGGER notes_search_update BEFORE INSERT OR UPDATE OF title, content ON notes FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn(); -- ========================================= -- 17. AUDIT LOG -- ========================================= CREATE TABLE IF NOT EXISTS audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), actor_id UUID REFERENCES users(id) ON DELETE SET NULL, action VARCHAR(100) NOT NULL, resource_type VARCHAR(50) NOT NULL, resource_id VARCHAR(255), metadata JSONB DEFAULT '{}'::jsonb, ip_address VARCHAR(45), user_agent TEXT DEFAULT '', created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id); CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action); COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';