diff --git a/server/database/migrations/001_core.sql b/server/database/migrations/001_core.sql index 7b2c1f6..8b59c49 100644 --- a/server/database/migrations/001_core.sql +++ b/server/database/migrations/001_core.sql @@ -1,11 +1,9 @@ -- ========================================== -- Chat Switchboard — 001 Core -- ========================================== --- Users, authentication, platform policies, and global settings. --- Foundation tables with no external FK dependencies. --- --- ICD §2 (Auth), §14 (User Profile & Settings), §16 (Platform Admin) --- v0.22.8: Domain-grouped migration (replaces monolith 001 + incrementals) +-- Users (with auth fields), refresh tokens, platform policies, +-- global settings, user presence. +-- Consolidated v0.28.0: merges 001 + 018 (auth_abstraction) + 016 (presence). -- ========================================== -- ── Extensions ────────────────────────────── @@ -32,7 +30,7 @@ CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(50) NOT NULL, email VARCHAR(255) NOT NULL, - password_hash TEXT NOT NULL, + password_hash TEXT, -- nullable for mTLS/OIDC display_name VARCHAR(100), avatar_url TEXT, role VARCHAR(20) DEFAULT 'user' @@ -40,6 +38,12 @@ CREATE TABLE IF NOT EXISTS users ( is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb, + -- Auth abstraction (v0.24.0) + auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' + CHECK (auth_source IN ('builtin', 'mtls', 'oidc')), + external_id TEXT, + handle VARCHAR(100) NOT NULL, + -- Vault: per-user encryption key (UEK) for BYOK API keys encrypted_uek BYTEA, uek_salt BYTEA, @@ -51,9 +55,12 @@ CREATE TABLE IF NOT EXISTS users ( last_login_at TIMESTAMPTZ ); --- Case-insensitive unique indexes (no bare UNIQUE constraint) +-- Case-insensitive unique indexes CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username)); CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email)); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(LOWER(handle)); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id + ON users(auth_source, external_id) WHERE external_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); @@ -62,6 +69,9 @@ 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(); +COMMENT ON COLUMN users.auth_source IS 'builtin=password, mtls=cert, oidc=SSO'; +COMMENT ON COLUMN users.external_id IS 'IdP subject claim (OIDC) or cert fingerprint (mTLS)'; +COMMENT ON COLUMN users.handle IS 'Unique @mention handle, auto-generated from username'; COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key'; COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation'; COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping'; @@ -96,7 +106,6 @@ CREATE TABLE IF NOT EXISTS platform_policies ( updated_at TIMESTAMPTZ DEFAULT NOW() ); --- Secure by default INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'false'), ('allow_user_personas', 'false'), @@ -131,3 +140,19 @@ INSERT INTO global_settings (key, value) VALUES "generation": { "primary": null, "fallback": null } }'::jsonb) ON CONFLICT (key) DO NOTHING; + + +-- ========================================= +-- USER PRESENCE (v0.23.1) +-- ========================================= + +CREATE TABLE IF NOT EXISTS user_presence ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status VARCHAR(20) NOT NULL DEFAULT 'online' + CHECK (status IN ('online', 'away', 'offline')) +); + +CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC); + +COMMENT ON TABLE user_presence IS 'Heartbeat-driven presence. Rows upserted every 30s by active clients.'; diff --git a/server/database/migrations/002_teams.sql b/server/database/migrations/002_teams.sql index b60b1e1..759cd26 100644 --- a/server/database/migrations/002_teams.sql +++ b/server/database/migrations/002_teams.sql @@ -1,8 +1,9 @@ -- ========================================== -- Chat Switchboard — 002 Teams & Access Control -- ========================================== --- Teams, team membership, groups (ACLs), and group membership. --- ICD §15 (Teams & Access Control) +-- Teams, team membership, groups (with permissions, budgets, source), +-- group membership, Everyone group seed. +-- Consolidated v0.28.0: merges 002 + 019 (source) + 020 (permissions). -- ========================================== -- ========================================= @@ -42,9 +43,8 @@ CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id); -- ========================================= --- GROUPS (ACLs) +-- GROUPS (ACLs + Permissions) -- ========================================= --- Pure access-control lists. Decouple resource visibility from team membership. CREATE TABLE IF NOT EXISTS groups ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -53,7 +53,16 @@ CREATE TABLE IF NOT EXISTS groups ( scope VARCHAR(20) NOT NULL DEFAULT 'global' CHECK (scope IN ('global', 'team')), team_id UUID REFERENCES teams(id) ON DELETE CASCADE, - created_by UUID NOT NULL REFERENCES users(id), + created_by UUID REFERENCES users(id), -- nullable for system-seeded + source VARCHAR(20) NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'oidc', 'system')), + + -- Fine-grained permissions (v0.24.2) + permissions JSONB NOT NULL DEFAULT '[]'::jsonb, + token_budget_daily BIGINT, + token_budget_monthly BIGINT, + allowed_models JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), @@ -70,8 +79,13 @@ DROP TRIGGER IF EXISTS groups_updated_at ON groups; CREATE TRIGGER groups_updated_at BEFORE UPDATE ON groups FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -COMMENT ON TABLE groups IS 'Access-control groups. Decouple resource visibility from team membership.'; -COMMENT ON COLUMN groups.scope IS 'global: admin-managed, can span teams. team: team-admin-managed, team-internal.'; +COMMENT ON TABLE groups IS 'Access-control groups with permission grants. Decouple resource visibility from team membership.'; +COMMENT ON COLUMN groups.scope IS 'global: admin-managed. team: team-admin-managed.'; +COMMENT ON COLUMN groups.source IS 'manual=admin-created, oidc=synced from IdP, system=platform-seeded'; +COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to group members'; +COMMENT ON COLUMN groups.token_budget_daily IS 'Daily token ceiling for members (NULL = unlimited)'; +COMMENT ON COLUMN groups.token_budget_monthly IS 'Monthly token ceiling for members (NULL = unlimited)'; +COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group may use. NULL = unrestricted.'; CREATE TABLE IF NOT EXISTS group_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -84,3 +98,16 @@ CREATE TABLE IF NOT EXISTS group_members ( CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); + +-- Seed the Everyone group with a stable well-known ID. +INSERT INTO groups (id, name, description, scope, created_by, source, permissions) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Everyone', + 'Implicit group — all authenticated users receive these permissions.', + 'global', + NULL, + 'system', + '["model.use","kb.read","channel.create"]'::jsonb +) +ON CONFLICT (id) DO NOTHING; diff --git a/server/database/migrations/003_providers.sql b/server/database/migrations/003_providers.sql index 89ae00f..52e0c64 100644 --- a/server/database/migrations/003_providers.sql +++ b/server/database/migrations/003_providers.sql @@ -3,7 +3,7 @@ -- ========================================== -- Provider configs, model catalog, pricing, health, capability -- overrides, and routing policies. --- ICD §10 (Providers & Routing), §11 (Models & Preferences) +-- Consolidated v0.28.0: adds capability_match policy type. -- ========================================== -- ========================================= @@ -110,7 +110,7 @@ CREATE TABLE IF NOT EXISTS model_pricing ( -- ========================================= --- PROVIDER HEALTH (v0.22.0) +-- PROVIDER HEALTH -- ========================================= CREATE TABLE IF NOT EXISTS provider_health ( @@ -135,7 +135,7 @@ CREATE INDEX IF NOT EXISTS idx_provider_health_window -- ========================================= --- CAPABILITY OVERRIDES (v0.22.0) +-- CAPABILITY OVERRIDES -- ========================================= CREATE TABLE IF NOT EXISTS capability_overrides ( @@ -155,7 +155,7 @@ CREATE INDEX IF NOT EXISTS idx_capability_overrides_model -- ========================================= --- ROUTING POLICIES (v0.22.2) +-- ROUTING POLICIES -- ========================================= CREATE TABLE IF NOT EXISTS routing_policies ( @@ -166,7 +166,7 @@ CREATE TABLE IF NOT EXISTS routing_policies ( team_id UUID REFERENCES teams(id) ON DELETE CASCADE, priority INTEGER NOT NULL DEFAULT 100, policy_type VARCHAR(30) NOT NULL - CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias')), + CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias', 'capability_match')), config JSONB NOT NULL DEFAULT '{}'::jsonb, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/server/database/migrations/005_channels.sql b/server/database/migrations/005_channels.sql index fe48022..80c29a5 100644 --- a/server/database/migrations/005_channels.sql +++ b/server/database/migrations/005_channels.sql @@ -1,9 +1,14 @@ -- ========================================== -- Chat Switchboard — 005 Channels & Conversations -- ========================================== --- Channels, messages, participants, models, cursors, folders, user prefs. --- ICD §3 (Channels & Conversations) --- Note: project_id and workspace_id FKs added by 010_projects.sql +-- Channels (all types, all columns), messages, participants, +-- models, cursors, folders, user prefs, session participants. +-- Consolidated v0.28.0: merges 005 + 016 (multiuser) + 017 (unread) +-- + 021 (sessions) + 024 (workflow instance cols) + 026 (service type) +-- + v0.28.0 (kb_auto_inject). +-- +-- Note: project_id/workspace_id FKs added by 010_projects.sql. +-- Note: workflow_id FK added by 018_workflows.sql. -- ========================================== -- ========================================= @@ -38,17 +43,43 @@ CREATE TABLE IF NOT EXISTS channels ( title VARCHAR(500) NOT NULL, description TEXT, type VARCHAR(20) DEFAULT 'direct' - CHECK (type IN ('direct', 'group', 'workflow')), + CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow', 'service')), 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, - folder TEXT, + folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, + folder TEXT, -- legacy text folder (compat) team_id UUID REFERENCES teams(id) ON DELETE SET NULL, settings JSONB DEFAULT '{}'::jsonb, tags TEXT[], + + -- Multi-user (v0.23.1) + ai_mode VARCHAR(20) NOT NULL DEFAULT 'auto' + CHECK (ai_mode IN ('auto', 'mention_only', 'off')), + topic TEXT, + + -- Anonymous sessions (v0.24.3) + allow_anonymous BOOLEAN NOT NULL DEFAULT false, + + -- Workflow instance columns (v0.26.0) + -- workflow_id FK added by 018_workflows.sql (deferred — table doesn't exist yet) + workflow_id UUID, + workflow_version INTEGER, + current_stage INTEGER DEFAULT 0, + stage_data JSONB DEFAULT '{}'::jsonb, + workflow_status TEXT DEFAULT 'active' + CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), + last_activity_at TIMESTAMPTZ DEFAULT NOW(), + + -- KB auto-injection (v0.28.0) + kb_auto_inject BOOLEAN NOT NULL DEFAULT false, + + -- Cross-domain FKs (added by 010_projects.sql) + project_id UUID, + workspace_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -58,22 +89,27 @@ 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); +CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE project_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active'; + 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(); --- Deferred FK: channels.folder_id → folders.id -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder' - ) THEN - ALTER TABLE channels ADD CONSTRAINT fk_channels_folder - FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL; - END IF; -END $$; -CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL; - -COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel'; +COMMENT ON COLUMN channels.type IS 'direct=single-user, dm=human-to-human, group=multi-participant, channel=named persistent, workflow=staged, service=autonomous task'; +COMMENT ON COLUMN channels.ai_mode IS 'auto=respond always, mention_only=@mention required, off=AI disabled'; +COMMENT ON COLUMN channels.topic IS 'Short channel description shown in header'; +COMMENT ON COLUMN channels.allow_anonymous IS 'When true, unauthenticated visitors can join via session token'; +COMMENT ON COLUMN channels.workflow_id IS 'FK to workflows — set when type=workflow (constraint added by 018)'; +COMMENT ON COLUMN channels.workflow_version IS 'Pinned version number from workflow_versions'; +COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stage'; +COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages'; +COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled'; +COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep'; +COMMENT ON COLUMN channels.kb_auto_inject IS 'When true, top-K KB chunks auto-prepend to context'; -- ========================================= @@ -113,12 +149,6 @@ COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed) -- ========================================= -- CHANNEL PARTICIPANTS & MODELS -- ========================================= --- ICD §3.7: Polymorphic participant system. --- participant_type determines what participant_id references: --- user → users.id --- persona → personas.id --- session → opaque session token --- ========================================= CREATE TABLE IF NOT EXISTS channel_participants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -132,6 +162,7 @@ CREATE TABLE IF NOT EXISTS channel_participants ( avatar_url TEXT, joined_at TIMESTAMPTZ DEFAULT NOW(), last_read_at TIMESTAMPTZ DEFAULT NOW(), + last_read_message_id UUID, -- v0.23.2: unread cursor UNIQUE(channel_id, participant_type, participant_id) ); @@ -200,3 +231,25 @@ CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(u 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(); + + +-- ========================================= +-- SESSION PARTICIPANTS (v0.24.3) +-- ========================================= + +CREATE TABLE IF NOT EXISTS session_participants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_token TEXT NOT NULL UNIQUE, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + display_name TEXT NOT NULL DEFAULT 'Visitor', + fingerprint TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_session_participants_channel + ON session_participants(channel_id); + +CREATE INDEX IF NOT EXISTS idx_session_participants_token + ON session_participants(session_token); + +COMMENT ON TABLE session_participants IS 'Ephemeral identities for anonymous workflow channel visitors.'; diff --git a/server/database/migrations/008_memory.sql b/server/database/migrations/008_memory.sql index 3f3aecd..34b8e4e 100644 --- a/server/database/migrations/008_memory.sql +++ b/server/database/migrations/008_memory.sql @@ -2,7 +2,7 @@ -- Chat Switchboard — 008 Memory -- ========================================== -- Long-term memory across conversations, extraction tracking. --- ICD §9 (Memory) +-- Consolidated v0.28.0: adds last_compacted_at + decay_rate. -- ========================================== -- ========================================= @@ -21,6 +21,11 @@ CREATE TABLE IF NOT EXISTS memories ( status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending_review', 'archived')), embedding vector(3072), + + -- Compaction support (v0.28.0) + last_compacted_at TIMESTAMPTZ, + decay_rate REAL NOT NULL DEFAULT 0.0, + created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); @@ -47,6 +52,8 @@ COMMENT ON TABLE memories IS 'Long-term memory facts persisted across conversati COMMENT ON COLUMN memories.scope IS 'user = personal facts; persona = shared; persona_user = per-user within a persona'; COMMENT ON COLUMN memories.owner_id IS 'user_id for user scope, persona_id for persona/persona_user scopes'; COMMENT ON COLUMN memories.confidence IS '0.0-1.0 confidence score — higher = more certain'; +COMMENT ON COLUMN memories.last_compacted_at IS 'Last time this memory was evaluated by the compaction process'; +COMMENT ON COLUMN memories.decay_rate IS 'Rate at which confidence decreases over time (0.0 = no decay)'; CREATE OR REPLACE FUNCTION update_memories_updated_at() RETURNS TRIGGER AS $$ @@ -63,7 +70,7 @@ CREATE TRIGGER trg_memories_updated_at -- ========================================= --- MEMORY EXTRACTION LOG (v0.18.0 Phase 2) +-- MEMORY EXTRACTION LOG -- ========================================= CREATE TABLE IF NOT EXISTS memory_extraction_log ( diff --git a/server/database/migrations/010_projects.sql b/server/database/migrations/010_projects.sql index e5c3d17..55430e8 100644 --- a/server/database/migrations/010_projects.sql +++ b/server/database/migrations/010_projects.sql @@ -94,16 +94,26 @@ CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id); -- ========================================= -- CROSS-DOMAIN FK COLUMNS -- ========================================= --- Denormalized project_id + workspace_id on channels. +-- Deferred FKs: channels.project_id and workspace_id declared in 005 +-- without FK constraints (referenced tables didn't exist yet). -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_project' + ) THEN + ALTER TABLE channels ADD CONSTRAINT fk_channels_project + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL; + END IF; +END $$; -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE project_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_workspace' + ) THEN + ALTER TABLE channels ADD CONSTRAINT fk_channels_workspace + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; + END IF; +END $$; -- ========================================= diff --git a/server/database/migrations/013_files.sql b/server/database/migrations/013_files.sql index 07f5af0..b8d6b9f 100644 --- a/server/database/migrations/013_files.sql +++ b/server/database/migrations/013_files.sql @@ -1,8 +1,8 @@ -- ========================================== -- Chat Switchboard — 013 Files -- ========================================== --- Unified files table replacing attachments. --- Handles both upgrade (attachments exists) and fresh install. +-- Unified files table. Fresh install — no legacy attachments migration. +-- Consolidated v0.28.0: removes attachments→files migration dance. -- ========================================== CREATE TABLE IF NOT EXISTS files ( @@ -25,38 +25,6 @@ CREATE TABLE IF NOT EXISTS files ( updated_at TIMESTAMPTZ DEFAULT NOW() ); --- Ensure attachments table exists so the INSERT is always valid. --- Upgrade: no-op (table already has data). Fresh install: empty table. -CREATE TABLE IF NOT EXISTS attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID, user_id UUID, message_id UUID, project_id UUID, - filename VARCHAR(255), content_type VARCHAR(127), size_bytes BIGINT, - storage_key TEXT, extracted_text TEXT, metadata JSONB, created_at TIMESTAMPTZ -); - -INSERT INTO files ( - id, channel_id, message_id, user_id, project_id, - origin, filename, content_type, size_bytes, storage_key, - display_hint, extracted_text, metadata, created_at, updated_at -) -SELECT - id, channel_id, message_id, user_id, project_id, - 'user_upload', - filename, content_type, size_bytes, storage_key, - CASE - WHEN content_type LIKE 'image/%' THEN 'inline' - WHEN content_type LIKE 'video/%' THEN 'inline' - WHEN content_type LIKE 'audio/%' THEN 'inline' - WHEN content_type = 'application/pdf' THEN 'thumbnail' - WHEN content_type LIKE 'text/%' THEN 'inline' - ELSE 'download' - END, - extracted_text, metadata, created_at, created_at -FROM attachments -ON CONFLICT (id) DO NOTHING; - -DROP TABLE IF EXISTS attachments; - CREATE INDEX IF NOT EXISTS idx_files_channel ON files(channel_id); CREATE INDEX IF NOT EXISTS idx_files_message ON files(message_id); CREATE INDEX IF NOT EXISTS idx_files_user ON files(user_id); diff --git a/server/database/migrations/022_surfaces.sql b/server/database/migrations/016_surfaces.sql similarity index 67% rename from server/database/migrations/022_surfaces.sql rename to server/database/migrations/016_surfaces.sql index a500fa7..b265b0f 100644 --- a/server/database/migrations/022_surfaces.sql +++ b/server/database/migrations/016_surfaces.sql @@ -1,6 +1,10 @@ --- 022_surfaces.sql --- Surface registry for dynamic surface lifecycle (v0.25.0) --- Core surfaces are seeded at startup. Extension surfaces are uploaded via admin. +-- ========================================== +-- Chat Switchboard — 016 Surfaces +-- ========================================== +-- Surface registry for dynamic surface lifecycle. +-- Core surfaces seeded at startup. Extension surfaces uploaded via admin. +-- Consolidated v0.28.0: renumbered from 022. +-- ========================================== CREATE TABLE IF NOT EXISTS surface_registry ( id TEXT PRIMARY KEY, diff --git a/server/database/migrations/016_v023_multiuser.sql b/server/database/migrations/016_v023_multiuser.sql deleted file mode 100644 index ea662e5..0000000 --- a/server/database/migrations/016_v023_multiuser.sql +++ /dev/null @@ -1,46 +0,0 @@ --- ========================================== --- Chat Switchboard — 006 Multi-User: DMs + Channels --- ========================================== --- Extends channels with 'dm' and 'channel' types, ai_mode, and topic. --- Adds presence tracking table. --- ICD §3 (Channels & Conversations) — v0.23.1 --- ========================================== - --- ── Channel type extension ─────────────────────────────────────────── --- Add 'dm' (human-to-human, AI silent unless @mentioned) --- Add 'channel' (named persistent space, configurable ai_mode) --- Existing 'direct', 'group', 'workflow' are unchanged. -ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check; -ALTER TABLE channels ADD CONSTRAINT channels_type_check - CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow')); - --- ── ai_mode ────────────────────────────────────────────────────────── --- auto : AI responds to every message (existing behavior for 'direct') --- mention_only : AI silent unless @mentioned (default for 'dm') --- off : AI disabled entirely -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS ai_mode VARCHAR(20) NOT NULL DEFAULT 'auto' - CHECK (ai_mode IN ('auto', 'mention_only', 'off')); - --- Back-fill: DM channels should be mention_only --- (No existing rows should have type='dm' yet, but be safe) -UPDATE channels SET ai_mode = 'mention_only' WHERE type = 'dm' AND ai_mode = 'auto'; - --- ── topic ───────────────────────────────────────────────────────────── -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS topic TEXT; - --- ── Presence (in-memory heartbeat, DB stores last_seen for persistence) ── --- Not queried at high frequency — updated by heartbeat (30s), read on load. -CREATE TABLE IF NOT EXISTS user_presence ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), - status VARCHAR(20) NOT NULL DEFAULT 'online' - CHECK (status IN ('online', 'away', 'offline')) -); - -CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC); - -COMMENT ON TABLE user_presence IS 'Heartbeat-driven presence. Rows upserted every 30s by active clients.'; -COMMENT ON COLUMN channels.ai_mode IS 'auto=respond always, mention_only=@mention required, off=AI disabled'; -COMMENT ON COLUMN channels.topic IS 'Short channel description shown in header'; diff --git a/server/database/migrations/017_auth.sql b/server/database/migrations/017_auth.sql new file mode 100644 index 0000000..db858e0 --- /dev/null +++ b/server/database/migrations/017_auth.sql @@ -0,0 +1,18 @@ +-- ========================================== +-- Chat Switchboard — 017 Auth Providers +-- ========================================== +-- OIDC authorization state tracking (ephemeral). +-- Consolidated v0.28.0: renumbered from 019. groups.source moved to 002. +-- ========================================== + +CREATE TABLE IF NOT EXISTS oidc_auth_state ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_to TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_oidc_state_created + ON oidc_auth_state(created_at); + +COMMENT ON TABLE oidc_auth_state IS 'Ephemeral OIDC authorization code flow state. Rows deleted after callback.'; diff --git a/server/database/migrations/017_unread.sql b/server/database/migrations/017_unread.sql deleted file mode 100644 index a3ffb76..0000000 --- a/server/database/migrations/017_unread.sql +++ /dev/null @@ -1,10 +0,0 @@ --- ========================================== --- Chat Switchboard — 017 Unread Counts --- ========================================== --- Adds last_read_message_id to channel_participants for --- per-user unread count computation. --- ICD §3 (Channels & Conversations) — v0.23.2 --- ========================================== - -ALTER TABLE channel_participants - ADD COLUMN IF NOT EXISTS last_read_message_id UUID; diff --git a/server/database/migrations/018_auth_abstraction.sql b/server/database/migrations/018_auth_abstraction.sql deleted file mode 100644 index 1bebc83..0000000 --- a/server/database/migrations/018_auth_abstraction.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Chat Switchboard — 018 Auth Abstraction (v0.24.0) -ALTER TABLE users - ADD COLUMN IF NOT EXISTS auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' - CHECK (auth_source IN ('builtin', 'mtls', 'oidc')); -ALTER TABLE users ADD COLUMN IF NOT EXISTS external_id TEXT; -ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(100); -ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; -UPDATE users SET handle = LOWER(REPLACE(REPLACE(username, ' ', '-'), '_', '-')) - WHERE handle IS NULL; -ALTER TABLE users ALTER COLUMN handle SET NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(LOWER(handle)); -CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id - ON users(auth_source, external_id) WHERE external_id IS NOT NULL; diff --git a/server/database/migrations/023_workflows.sql b/server/database/migrations/018_workflows.sql similarity index 55% rename from server/database/migrations/023_workflows.sql rename to server/database/migrations/018_workflows.sql index 643b3aa..68d6427 100644 --- a/server/database/migrations/023_workflows.sql +++ b/server/database/migrations/018_workflows.sql @@ -1,12 +1,18 @@ --- 023_workflows.sql --- Workflow definitions, stages, and version snapshots (v0.26.1) --- Depends on: teams, personas, users +-- ========================================== +-- Chat Switchboard — 018 Workflows +-- ========================================== +-- Workflow definitions, stages, version snapshots, assignments. +-- Consolidated v0.28.0: merges 023 + 025 (assignments) + 027 (webhooks). +-- Also adds deferred FK: channels.workflow_id → workflows.id. +-- ========================================== --- ── Workflow Definitions ──────────────────── +-- ========================================= +-- WORKFLOW DEFINITIONS +-- ========================================= CREATE TABLE IF NOT EXISTS workflows ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - team_id UUID REFERENCES teams(id) ON DELETE CASCADE, -- NULL = global + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, name TEXT NOT NULL, slug TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', @@ -15,29 +21,40 @@ CREATE TABLE IF NOT EXISTS workflows ( CHECK (entry_mode IN ('public_link', 'team_only')), is_active BOOLEAN NOT NULL DEFAULT false, version INTEGER NOT NULL DEFAULT 1, - on_complete JSONB, -- v0.27.0 chaining hook, NULL for now + on_complete JSONB, retention JSONB NOT NULL DEFAULT '{"mode": "archive"}', + + -- Webhooks (v0.27.3) + webhook_url TEXT, + webhook_secret TEXT, + created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Slug unique within scope: team-scoped workflows + global workflows CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug ON workflows(team_id, slug) WHERE team_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug ON workflows(slug) WHERE team_id IS NULL; - CREATE INDEX IF NOT EXISTS idx_workflows_active ON workflows(is_active) WHERE is_active = true; +DROP TRIGGER IF EXISTS workflows_updated_at ON workflows; +CREATE TRIGGER workflows_updated_at + BEFORE UPDATE ON workflows + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + COMMENT ON TABLE workflows IS 'Team-owned or global workflow definitions with staged processes.'; COMMENT ON COLUMN workflows.slug IS 'URL-safe identifier, unique within scope (team or global).'; COMMENT ON COLUMN workflows.branding IS '{"accent_color": "#hex", "logo_url": "...", "tagline": "..."}'; -COMMENT ON COLUMN workflows.on_complete IS 'v0.27.0: chaining hook — triggers another workflow on completion.'; +COMMENT ON COLUMN workflows.on_complete IS 'Chaining hook — triggers another workflow on completion.'; COMMENT ON COLUMN workflows.retention IS '{"mode": "archive"|"delete", "delete_after_days": N}'; --- ── Workflow Stages ───────────────────────── + +-- ========================================= +-- WORKFLOW STAGES +-- ========================================= CREATE TABLE IF NOT EXISTS workflow_stages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -61,7 +78,10 @@ COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stag COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate'; COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.'; --- ── Workflow Versions (immutable snapshots) ─ + +-- ========================================= +-- WORKFLOW VERSIONS (immutable snapshots) +-- ========================================= CREATE TABLE IF NOT EXISTS workflow_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -79,8 +99,44 @@ CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow COMMENT ON TABLE workflow_versions IS 'Immutable snapshots of workflow definitions. Active instances run against a pinned version.'; COMMENT ON COLUMN workflow_versions.snapshot IS 'Full JSON: definition + stages + persona tool grants at publish time.'; --- ── updated_at trigger ────────────────────── -CREATE TRIGGER workflows_updated_at - BEFORE UPDATE ON workflows - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +-- ========================================= +-- WORKFLOW ASSIGNMENTS +-- ========================================= + +CREATE TABLE IF NOT EXISTS workflow_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + stage INTEGER NOT NULL, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'unassigned' + CHECK (status IN ('unassigned', 'claimed', 'completed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + claimed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel + ON workflow_assignments(channel_id, stage); +CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status + ON workflow_assignments(team_id, status) WHERE status = 'unassigned'; +CREATE INDEX IF NOT EXISTS idx_workflow_assignments_user + ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL; + +COMMENT ON TABLE workflow_assignments IS 'Queue entries for human review stages in workflows.'; +COMMENT ON COLUMN workflow_assignments.status IS 'unassigned=waiting, claimed=being worked, completed=done'; + + +-- ========================================= +-- DEFERRED FK: channels.workflow_id → workflows +-- ========================================= + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_workflow' + ) THEN + ALTER TABLE channels ADD CONSTRAINT fk_channels_workflow + FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE SET NULL; + END IF; +END $$; diff --git a/server/database/migrations/019_auth_providers.sql b/server/database/migrations/019_auth_providers.sql deleted file mode 100644 index e5178e9..0000000 --- a/server/database/migrations/019_auth_providers.sql +++ /dev/null @@ -1,30 +0,0 @@ --- ========================================== --- Chat Switchboard — 019 Auth Providers --- ========================================== --- OIDC authorization state tracking. --- Group source column for OIDC-synced groups. --- ICD §1 (Users), §2 (Groups) — v0.24.1 --- ========================================== - --- ── OIDC authorization state (short-lived) ─────────────────────── --- Stores state + nonce for the authorization code flow. --- Rows deleted after callback completes. Stale rows cleaned by age. -CREATE TABLE IF NOT EXISTS oidc_auth_state ( - state TEXT PRIMARY KEY, - nonce TEXT NOT NULL, - redirect_to TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_oidc_state_created - ON oidc_auth_state(created_at); - --- ── Group source tracking ──────────────────────────────────────── --- Distinguishes manually-created groups from OIDC-synced groups. --- OIDC groups are managed exclusively by the sync process. -ALTER TABLE groups - ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'manual' - CHECK (source IN ('manual', 'oidc')); - -COMMENT ON TABLE oidc_auth_state IS 'Ephemeral OIDC authorization code flow state. Rows deleted after callback.'; -COMMENT ON COLUMN groups.source IS 'manual=admin-created, oidc=synced from IdP groups claim'; diff --git a/server/database/migrations/026_tasks.sql b/server/database/migrations/019_tasks.sql similarity index 84% rename from server/database/migrations/026_tasks.sql rename to server/database/migrations/019_tasks.sql index 9152875..759c223 100644 --- a/server/database/migrations/026_tasks.sql +++ b/server/database/migrations/019_tasks.sql @@ -1,11 +1,14 @@ --- 026_tasks.sql — Task scheduling for autonomous agents (v0.27.1) +-- ========================================== +-- Chat Switchboard — 019 Tasks +-- ========================================== +-- Task definitions and run history for autonomous agents. +-- Consolidated v0.28.0: merges 026 + 027 (webhook_secret inline). +-- ========================================== --- Extend channel type to include 'service' (no human participants) -ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check; -ALTER TABLE channels ADD CONSTRAINT channels_type_check - CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow', 'service')); +-- ========================================= +-- TASK DEFINITIONS +-- ========================================= --- Task definitions CREATE TABLE IF NOT EXISTS tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -38,6 +41,7 @@ CREATE TABLE IF NOT EXISTS tasks ( CHECK (output_mode IN ('channel', 'note', 'webhook')), output_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL, webhook_url TEXT, + webhook_secret TEXT, -- Provider routing provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL, @@ -58,7 +62,11 @@ CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks (next_run_at) WHERE is_ac CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id); CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id); --- Task run history + +-- ========================================= +-- TASK RUN HISTORY +-- ========================================= + CREATE TABLE IF NOT EXISTS task_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, diff --git a/server/database/migrations/020_permissions.sql b/server/database/migrations/020_permissions.sql deleted file mode 100644 index a30b7bf..0000000 --- a/server/database/migrations/020_permissions.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 020_permissions.sql --- Fine-grained permissions on groups (v0.24.2) - --- Extend the groups.source CHECK to include 'system' (system-seeded groups). -ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_source_check; -ALTER TABLE groups ADD CONSTRAINT groups_source_check - CHECK (source IN ('manual', 'oidc', 'system')); - --- created_by is meaningless for system-seeded groups; allow NULL. -ALTER TABLE groups ALTER COLUMN created_by DROP NOT NULL; - -ALTER TABLE groups - ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb, - ADD COLUMN IF NOT EXISTS token_budget_daily BIGINT, - ADD COLUMN IF NOT EXISTS token_budget_monthly BIGINT, - ADD COLUMN IF NOT EXISTS allowed_models JSONB; - -COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to group members'; -COMMENT ON COLUMN groups.token_budget_daily IS 'Daily token ceiling for members (NULL = unlimited)'; -COMMENT ON COLUMN groups.token_budget_monthly IS 'Monthly token ceiling for members (NULL = unlimited)'; -COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group may use. NULL = unrestricted.'; - --- Seed the Everyone group with a stable well-known ID. --- All authenticated users receive its permissions implicitly (no membership row needed). --- source='system' prevents deletion via the store guard. -INSERT INTO groups (id, name, description, scope, created_by, source, permissions) -VALUES ( - '00000000-0000-0000-0000-000000000001', - 'Everyone', - 'Implicit group — all authenticated users receive these permissions.', - 'global', - NULL, - 'system', - '["model.use","kb.read","channel.create"]'::jsonb -) -ON CONFLICT (id) DO NOTHING; diff --git a/server/database/migrations/021_sessions.sql b/server/database/migrations/021_sessions.sql deleted file mode 100644 index 4fd0e54..0000000 --- a/server/database/migrations/021_sessions.sql +++ /dev/null @@ -1,24 +0,0 @@ --- 021_sessions.sql --- Anonymous / session participants for workflow channels (v0.24.3) - -CREATE TABLE IF NOT EXISTS session_participants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - session_token TEXT NOT NULL UNIQUE, - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - display_name TEXT NOT NULL DEFAULT 'Visitor', - fingerprint TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_session_participants_channel - ON session_participants(channel_id); - -CREATE INDEX IF NOT EXISTS idx_session_participants_token - ON session_participants(session_token); - -COMMENT ON TABLE session_participants IS 'Ephemeral identities for anonymous workflow channel visitors.'; - --- Allow channels to opt in to anonymous session access. -ALTER TABLE channels ADD COLUMN IF NOT EXISTS allow_anonymous BOOLEAN NOT NULL DEFAULT FALSE; - -COMMENT ON COLUMN channels.allow_anonymous IS 'When true, unauthenticated visitors can join via session token (workflow channels only).'; diff --git a/server/database/migrations/024_workflow_instances.sql b/server/database/migrations/024_workflow_instances.sql deleted file mode 100644 index accdb37..0000000 --- a/server/database/migrations/024_workflow_instances.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 024_workflow_instances.sql --- Workflow instance columns on channels (v0.26.2) --- Channels with type='workflow' become runtime containers for workflow instances. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL, - ADD COLUMN IF NOT EXISTS workflow_version INTEGER, - ADD COLUMN IF NOT EXISTS current_stage INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS stage_data JSONB DEFAULT '{}', - ADD COLUMN IF NOT EXISTS workflow_status TEXT DEFAULT 'active' - CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), - ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ DEFAULT NOW(); - -CREATE INDEX IF NOT EXISTS idx_channels_workflow - ON channels(workflow_id) WHERE workflow_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_channels_workflow_status - ON channels(workflow_status) WHERE workflow_status = 'active'; - -COMMENT ON COLUMN channels.workflow_id IS 'FK to workflows — set when type=workflow'; -COMMENT ON COLUMN channels.workflow_version IS 'Pinned version number from workflow_versions'; -COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stage'; -COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages'; -COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled'; -COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep'; diff --git a/server/database/migrations/025_workflow_assignments.sql b/server/database/migrations/025_workflow_assignments.sql deleted file mode 100644 index eafd84e..0000000 --- a/server/database/migrations/025_workflow_assignments.sql +++ /dev/null @@ -1,27 +0,0 @@ --- 025_workflow_assignments.sql --- Assignment queue for workflow stages (v0.26.4) - -CREATE TABLE IF NOT EXISTS workflow_assignments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - stage INTEGER NOT NULL, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'unassigned' - CHECK (status IN ('unassigned', 'claimed', 'completed')), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - claimed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel - ON workflow_assignments(channel_id, stage); - -CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status - ON workflow_assignments(team_id, status) WHERE status = 'unassigned'; - -CREATE INDEX IF NOT EXISTS idx_workflow_assignments_user - ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL; - -COMMENT ON TABLE workflow_assignments IS 'Queue entries for human review stages in workflows.'; -COMMENT ON COLUMN workflow_assignments.status IS 'unassigned=waiting, claimed=being worked, completed=done'; diff --git a/server/database/migrations/027_webhooks.sql b/server/database/migrations/027_webhooks.sql deleted file mode 100644 index 66e2866..0000000 --- a/server/database/migrations/027_webhooks.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 027_webhooks.sql — Webhook secrets + workflow webhook support (v0.27.3) - --- Per-task webhook secret for HMAC signing -ALTER TABLE tasks ADD COLUMN IF NOT EXISTS webhook_secret TEXT; - --- Workflows can also fire webhooks on completion -ALTER TABLE workflows ADD COLUMN IF NOT EXISTS webhook_url TEXT; -ALTER TABLE workflows ADD COLUMN IF NOT EXISTS webhook_secret TEXT; diff --git a/server/database/migrations/sqlite/001_core.sql b/server/database/migrations/sqlite/001_core.sql index 7fee36d..5b0fa17 100644 --- a/server/database/migrations/sqlite/001_core.sql +++ b/server/database/migrations/sqlite/001_core.sql @@ -1,28 +1,24 @@ -- ========================================== -- Chat Switchboard — 001 Core (SQLite) -- ========================================== --- Users, authentication, platform policies, global settings. --- v0.22.8: Domain-grouped migration --- ========================================== +-- Consolidated v0.28.0 PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; --- ========================================= --- USERS --- ========================================= - CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, - password_hash TEXT NOT NULL, + password_hash TEXT, display_name TEXT, avatar_url TEXT, - role TEXT DEFAULT 'user' - CHECK (role IN ('user', 'admin')), + role TEXT DEFAULT 'user' CHECK (role IN ('user', 'admin')), is_active INTEGER DEFAULT 1, settings TEXT DEFAULT '{}', + auth_source TEXT NOT NULL DEFAULT 'builtin', + external_id TEXT, + handle TEXT NOT NULL, encrypted_uek BLOB, uek_salt BLOB, uek_nonce BLOB, @@ -34,6 +30,8 @@ CREATE TABLE IF NOT EXISTS users ( CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (username COLLATE NOCASE); CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (email COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(handle COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id ON users(auth_source, external_id); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); @@ -41,11 +39,6 @@ CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at BEGIN UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id; END; - --- ========================================= --- AUTH — REFRESH TOKENS --- ========================================= - CREATE TABLE IF NOT EXISTS refresh_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -58,11 +51,6 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( 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); - --- ========================================= --- PLATFORM POLICIES --- ========================================= - CREATE TABLE IF NOT EXISTS platform_policies ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -79,11 +67,6 @@ INSERT OR IGNORE INTO platform_policies (key, value) VALUES ('allow_team_providers', 'true'), ('kb_direct_access', 'true'); - --- ========================================= --- GLOBAL SETTINGS --- ========================================= - CREATE TABLE IF NOT EXISTS global_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '{}', @@ -95,4 +78,12 @@ INSERT OR IGNORE INTO global_settings (key, value) VALUES ('registration', '{"enabled": true}'), ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'), ('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'), - ('model_roles', '{"utility": {"primary": null, "fallback": null}, "embedding": {"primary": null, "fallback": null}, "generation": {"primary": null, "fallback": null}}'); + ('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}'); + +CREATE TABLE IF NOT EXISTS user_presence ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_seen TEXT NOT NULL DEFAULT (datetime('now')), + status TEXT NOT NULL DEFAULT 'online' +); + +CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC); diff --git a/server/database/migrations/sqlite/002_teams.sql b/server/database/migrations/sqlite/002_teams.sql index fa9b38f..015851d 100644 --- a/server/database/migrations/sqlite/002_teams.sql +++ b/server/database/migrations/sqlite/002_teams.sql @@ -1,4 +1,5 @@ -- Chat Switchboard — 002 Teams & Access Control (SQLite) +-- Consolidated v0.28.0 CREATE TABLE IF NOT EXISTS teams ( id TEXT PRIMARY KEY, @@ -17,8 +18,7 @@ CREATE TABLE IF NOT EXISTS team_members ( id TEXT PRIMARY KEY, team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'member' - CHECK (role IN ('admin', 'member')), + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')), joined_at TEXT DEFAULT (datetime('now')), UNIQUE(team_id, user_id) ); @@ -30,22 +30,18 @@ CREATE TABLE IF NOT EXISTS groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT 'global' - CHECK (scope IN ('global', 'team')), + scope TEXT NOT NULL DEFAULT 'global' CHECK (scope IN ('global', 'team')), team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, - created_by TEXT NOT NULL REFERENCES users(id), + created_by TEXT REFERENCES users(id), + source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc', 'system')), + permissions TEXT NOT NULL DEFAULT '[]', + token_budget_daily INTEGER, + token_budget_monthly INTEGER, + allowed_models TEXT, created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - - CHECK ( - (scope = 'global' AND team_id IS NULL) OR - (scope = 'team' AND team_id IS NOT NULL) - ) + updated_at TEXT DEFAULT (datetime('now')) ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope - ON groups(name, COALESCE(team_id, '')); - CREATE TABLE IF NOT EXISTS group_members ( id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, @@ -57,3 +53,14 @@ CREATE TABLE IF NOT EXISTS group_members ( CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id); CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); + +INSERT OR IGNORE INTO groups + (id, name, description, scope, created_by, source, permissions, created_at, updated_at) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Everyone', + 'Implicit group — all authenticated users receive these permissions.', + 'global', NULL, 'system', + '["model.use","kb.read","channel.create"]', + datetime('now'), datetime('now') +); diff --git a/server/database/migrations/sqlite/003_providers.sql b/server/database/migrations/sqlite/003_providers.sql index b40a88b..4328830 100644 --- a/server/database/migrations/sqlite/003_providers.sql +++ b/server/database/migrations/sqlite/003_providers.sql @@ -1,4 +1,5 @@ -- Chat Switchboard — 003 Providers & Routing (SQLite) +-- Consolidated v0.28.0: adds capability_match policy type CREATE TABLE IF NOT EXISTS provider_configs ( id TEXT PRIMARY KEY, @@ -19,8 +20,7 @@ CREATE TABLE IF NOT EXISTS provider_configs ( proxy_mode TEXT NOT NULL DEFAULT 'system' CHECK (proxy_mode IN ('system', 'direct', 'custom')), proxy_url TEXT, created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - CHECK (proxy_mode != 'custom' OR proxy_url IS NOT NULL) + updated_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope); @@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS provider_health ( UNIQUE (provider_config_id, window_start) ); -CREATE INDEX IF NOT EXISTS idx_provider_health_window ON provider_health(provider_config_id, window_start); +CREATE INDEX IF NOT EXISTS idx_provider_health_window + ON provider_health (provider_config_id, window_start DESC); CREATE TABLE IF NOT EXISTS capability_overrides ( id TEXT PRIMARY KEY, @@ -98,7 +99,8 @@ CREATE TABLE IF NOT EXISTS routing_policies ( scope TEXT NOT NULL DEFAULT 'global' CHECK (scope IN ('global', 'team')), team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, priority INTEGER NOT NULL DEFAULT 100, - policy_type TEXT NOT NULL CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias')), + policy_type TEXT NOT NULL + CHECK (policy_type IN ('provider_prefer', 'team_route', 'cost_limit', 'model_alias', 'capability_match')), config TEXT NOT NULL DEFAULT '{}', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -106,4 +108,3 @@ CREATE TABLE IF NOT EXISTS routing_policies ( ); CREATE INDEX IF NOT EXISTS idx_routing_policies_active ON routing_policies(is_active, priority); -CREATE INDEX IF NOT EXISTS idx_routing_policies_team ON routing_policies(team_id); diff --git a/server/database/migrations/sqlite/005_channels.sql b/server/database/migrations/sqlite/005_channels.sql index fb4b05b..6b55cce 100644 --- a/server/database/migrations/sqlite/005_channels.sql +++ b/server/database/migrations/sqlite/005_channels.sql @@ -1,4 +1,5 @@ -- Chat Switchboard — 005 Channels & Conversations (SQLite) +-- Consolidated v0.28.0 CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY, @@ -19,7 +20,8 @@ CREATE TABLE IF NOT EXISTS channels ( user_id TEXT REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, description TEXT, - type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'workflow')), + type TEXT DEFAULT 'direct' + CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow', 'service')), model TEXT, system_prompt TEXT, provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, @@ -30,6 +32,17 @@ CREATE TABLE IF NOT EXISTS channels ( team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, settings TEXT DEFAULT '{}', tags TEXT DEFAULT '[]', + ai_mode TEXT NOT NULL DEFAULT 'auto', + topic TEXT, + allow_anonymous INTEGER NOT NULL DEFAULT 0, + workflow_id TEXT, + workflow_version INTEGER, + current_stage INTEGER DEFAULT 0, + stage_data TEXT DEFAULT '{}', + workflow_status TEXT DEFAULT 'active' + CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')), + last_activity_at TEXT DEFAULT (datetime('now')), + kb_auto_inject INTEGER NOT NULL DEFAULT 0, project_id TEXT, workspace_id TEXT, created_at TEXT DEFAULT (datetime('now')), @@ -43,6 +56,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id); CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id); CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id); CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id); +CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, @@ -77,6 +91,7 @@ CREATE TABLE IF NOT EXISTS channel_participants ( avatar_url TEXT, joined_at TEXT DEFAULT (datetime('now')), last_read_at TEXT DEFAULT (datetime('now')), + last_read_message_id TEXT, UNIQUE(channel_id, participant_type, participant_id) ); @@ -96,13 +111,10 @@ CREATE TABLE IF NOT EXISTS channel_models ( added_at TEXT DEFAULT (datetime('now')) ); --- Raw model entries (no persona): unique per channel+model+provider CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_raw ON channel_models(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL; --- Persona entries: one per persona per channel CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_models_persona ON channel_models(channel_id, persona_id) WHERE persona_id IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id); CREATE TABLE IF NOT EXISTS channel_cursors ( @@ -132,3 +144,15 @@ CREATE TABLE IF NOT EXISTS user_model_settings ( ); CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id); + +CREATE TABLE IF NOT EXISTS session_participants ( + id TEXT PRIMARY KEY, + session_token TEXT NOT NULL UNIQUE, + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + display_name TEXT NOT NULL DEFAULT 'Visitor', + fingerprint TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_session_participants_channel ON session_participants(channel_id); +CREATE INDEX IF NOT EXISTS idx_session_participants_token ON session_participants(session_token); diff --git a/server/database/migrations/sqlite/008_memory.sql b/server/database/migrations/sqlite/008_memory.sql index a777697..e62c8cb 100644 --- a/server/database/migrations/sqlite/008_memory.sql +++ b/server/database/migrations/sqlite/008_memory.sql @@ -1,4 +1,5 @@ -- Chat Switchboard — 008 Memory (SQLite) +-- Consolidated v0.28.0: adds last_compacted_at + decay_rate CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, @@ -11,24 +12,33 @@ CREATE TABLE IF NOT EXISTS memories ( confidence REAL NOT NULL DEFAULT 1.0, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending_review', 'archived')), + last_compacted_at TEXT, + decay_rate REAL NOT NULL DEFAULT 0.0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); -CREATE INDEX IF NOT EXISTS idx_memories_scope_owner ON memories(scope, owner_id, status); -CREATE INDEX IF NOT EXISTS idx_memories_persona_user ON memories(owner_id, user_id); +CREATE INDEX IF NOT EXISTS idx_memories_scope_owner + ON memories(scope, owner_id, status); + +CREATE INDEX IF NOT EXISTS idx_memories_persona_user + ON memories(owner_id, user_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_unique_key ON memories(scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key); -CREATE INDEX IF NOT EXISTS idx_memories_source_channel ON memories(source_channel_id); + +CREATE INDEX IF NOT EXISTS idx_memories_source_channel + ON memories(source_channel_id); CREATE TABLE IF NOT EXISTS memory_extraction_log ( - id TEXT PRIMARY KEY, - channel_id TEXT NOT NULL, - user_id TEXT NOT NULL, + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, last_message_id TEXT NOT NULL, - extracted_at TEXT DEFAULT (datetime('now')), - memory_count INTEGER NOT NULL DEFAULT 0, + extracted_at TEXT DEFAULT (datetime('now')), + memory_count INTEGER NOT NULL DEFAULT 0, UNIQUE(channel_id, user_id) ); -CREATE INDEX IF NOT EXISTS idx_mem_extract_log_channel ON memory_extraction_log(channel_id); +CREATE INDEX IF NOT EXISTS idx_mem_extract_log_channel + ON memory_extraction_log(channel_id); diff --git a/server/database/migrations/sqlite/013_files.sql b/server/database/migrations/sqlite/013_files.sql index d536f07..e35b548 100644 --- a/server/database/migrations/sqlite/013_files.sql +++ b/server/database/migrations/sqlite/013_files.sql @@ -1,6 +1,5 @@ -- Chat Switchboard — 013 Files (SQLite) --- Unified files table replacing attachments. --- Handles both upgrade (attachments exists) and fresh install. +-- Consolidated v0.28.0: clean install, no legacy attachments migration CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, @@ -22,40 +21,8 @@ CREATE TABLE IF NOT EXISTS files ( updated_at TEXT DEFAULT (datetime('now')) ); --- Ensure attachments table exists so the INSERT is always valid. --- Upgrade: no-op (table already has data). Fresh install: empty table. -CREATE TABLE IF NOT EXISTS attachments ( - id TEXT PRIMARY KEY, channel_id TEXT, user_id TEXT, message_id TEXT, - project_id TEXT, filename TEXT, content_type TEXT, size_bytes INTEGER, - storage_key TEXT, extracted_text TEXT, metadata TEXT, created_at TEXT -); - -INSERT OR IGNORE INTO files ( - id, channel_id, message_id, user_id, project_id, - origin, filename, content_type, size_bytes, storage_key, - display_hint, extracted_text, metadata, created_at, updated_at -) -SELECT - id, channel_id, message_id, user_id, project_id, - 'user_upload', - filename, content_type, size_bytes, storage_key, - CASE - WHEN content_type LIKE 'image/%' THEN 'inline' - WHEN content_type LIKE 'video/%' THEN 'inline' - WHEN content_type LIKE 'audio/%' THEN 'inline' - WHEN content_type = 'application/pdf' THEN 'thumbnail' - WHEN content_type LIKE 'text/%' THEN 'inline' - ELSE 'download' - END, - extracted_text, metadata, created_at, created_at -FROM attachments; - -DROP TABLE IF EXISTS attachments; - CREATE INDEX IF NOT EXISTS idx_files_channel ON files(channel_id); CREATE INDEX IF NOT EXISTS idx_files_message ON files(message_id); CREATE INDEX IF NOT EXISTS idx_files_user ON files(user_id); CREATE INDEX IF NOT EXISTS idx_files_project ON files(project_id); CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin); -CREATE INDEX IF NOT EXISTS idx_files_orphan ON files(created_at) - WHERE message_id IS NULL AND origin = 'user_upload'; diff --git a/server/database/migrations/sqlite/016_surfaces.sql b/server/database/migrations/sqlite/016_surfaces.sql new file mode 100644 index 0000000..9bb6810 --- /dev/null +++ b/server/database/migrations/sqlite/016_surfaces.sql @@ -0,0 +1,12 @@ +-- Chat Switchboard — 016 Surfaces (SQLite) +-- Consolidated v0.28.0: was missing from SQLite entirely + +CREATE TABLE IF NOT EXISTS surface_registry ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + manifest TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'core', + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/server/database/migrations/sqlite/017_auth.sql b/server/database/migrations/sqlite/017_auth.sql new file mode 100644 index 0000000..cc851b1 --- /dev/null +++ b/server/database/migrations/sqlite/017_auth.sql @@ -0,0 +1,11 @@ +-- Chat Switchboard — 017 Auth Providers (SQLite) +-- Consolidated v0.28.0 + +CREATE TABLE IF NOT EXISTS oidc_auth_state ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_to TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_oidc_state_created ON oidc_auth_state(created_at); diff --git a/server/database/migrations/sqlite/018_workflows.sql b/server/database/migrations/sqlite/018_workflows.sql new file mode 100644 index 0000000..a056b21 --- /dev/null +++ b/server/database/migrations/sqlite/018_workflows.sql @@ -0,0 +1,75 @@ +-- Chat Switchboard — 018 Workflows (SQLite) +-- Consolidated v0.28.0: merges 023 + 025 + 027 + +CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + branding TEXT NOT NULL DEFAULT '{}', + entry_mode TEXT NOT NULL DEFAULT 'public_link' + CHECK (entry_mode IN ('public_link', 'team_only')), + is_active INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, + on_complete TEXT, + retention TEXT NOT NULL DEFAULT '{"mode": "archive"}', + webhook_url TEXT, + webhook_secret TEXT, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug + ON workflows(team_id, slug) WHERE team_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug + ON workflows(slug) WHERE team_id IS NULL; + +CREATE TABLE IF NOT EXISTS workflow_stages ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + name TEXT NOT NULL, + persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, + assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + form_template TEXT NOT NULL DEFAULT '{}', + history_mode TEXT NOT NULL DEFAULT 'full' + CHECK (history_mode IN ('full', 'summary', 'fresh')), + auto_transition INTEGER NOT NULL DEFAULT 0, + transition_rules TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow + ON workflow_stages(workflow_id, ordinal); + +CREATE TABLE IF NOT EXISTS workflow_versions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + snapshot TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (workflow_id, version_number) +); + +CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow + ON workflow_versions(workflow_id, version_number); + +CREATE TABLE IF NOT EXISTS workflow_assignments ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + stage INTEGER NOT NULL, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'unassigned' + CHECK (status IN ('unassigned', 'claimed', 'completed')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + claimed_at TEXT, + completed_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel + ON workflow_assignments(channel_id, stage); +CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status + ON workflow_assignments(team_id, status); diff --git a/server/database/migrations/sqlite/019_tasks.sql b/server/database/migrations/sqlite/019_tasks.sql new file mode 100644 index 0000000..0224684 --- /dev/null +++ b/server/database/migrations/sqlite/019_tasks.sql @@ -0,0 +1,55 @@ +-- Chat Switchboard — 019 Tasks (SQLite) +-- Consolidated v0.28.0: merges 026 + 027 + +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + scope TEXT NOT NULL DEFAULT 'personal', + task_type TEXT NOT NULL DEFAULT 'prompt', + persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL, + model_id TEXT, + system_prompt TEXT DEFAULT '', + user_prompt TEXT DEFAULT '', + workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL, + tool_grants TEXT, + schedule TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + is_active INTEGER NOT NULL DEFAULT 1, + max_tokens INTEGER NOT NULL DEFAULT 4096, + max_tool_calls INTEGER NOT NULL DEFAULT 10, + max_wall_clock INTEGER NOT NULL DEFAULT 300, + output_mode TEXT NOT NULL DEFAULT 'channel', + output_channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL, + webhook_url TEXT, + webhook_secret TEXT, + provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL, + notify_on_complete INTEGER NOT NULL DEFAULT 0, + notify_on_failure INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + next_run_at TEXT, + run_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks (next_run_at); +CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks (owner_id); +CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks (team_id); + +CREATE TABLE IF NOT EXISTS task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + channel_id TEXT REFERENCES channels(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'running', + started_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + tokens_used INTEGER DEFAULT 0, + tool_calls INTEGER DEFAULT 0, + wall_clock INTEGER DEFAULT 0, + error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs (task_id); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 24eedb9..61f0b84 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -253,46 +253,85 @@ func TruncateAll(t *testing.T) { } tables := []string{ + // Task system + "task_runs", + "tasks", + // Workflow system + "workflow_assignments", + "workflow_versions", + "workflow_stages", + "workflows", + // Sessions + "session_participants", + // Files + "files", + // Resource grants & groups "resource_grants", "group_members", "groups", + // Notifications + "notification_preferences", "notifications", + // Usage & audit "usage_log", - "model_pricing", - "notes", "audit_log", + // Channel internals "channel_cursors", "messages", "channel_models", "channel_participants", "channel_knowledge_bases", - "persona_knowledge_bases", + "user_model_settings", + // Projects "project_notes", "project_knowledge_bases", "project_channels", + // Knowledge bases + "persona_knowledge_bases", "kb_chunks", "kb_documents", "knowledge_bases", + // Notes + "note_links", + "notes", + // Memory + "memory_extraction_log", + "memories", + // Workspaces + "workspace_chunks", + "workspace_files", + "git_credentials", + "workspaces", + // Channels & folders "channels", + "folders", "projects", - "user_model_settings", + // Providers & health + "model_pricing", + "provider_health", + "capability_overrides", + "routing_policies", + "tool_health", "model_catalog", + // Personas "persona_grants", "persona_group_members", "persona_groups", "personas", "provider_configs", + // Teams & users "team_members", "teams", "refresh_tokens", + "user_presence", "users", + // Config & extensions "platform_policies", - "global_config", "global_settings", - "folders", - "attachments", "extension_user_settings", "extensions", + "surface_registry", + "oidc_auth_state", } if IsSQLite() {