-- v0.19.0: Projects / Workspaces -- -- New: projects table -- New: project_channels junction table (1:1 channel→project) -- New: project_knowledge_bases junction table -- New: project_notes junction table -- New: channels.project_id denormalized FK -- Changed: resource_grants.resource_type CHECK includes 'project' -- Deprecated: channels.folder (column retained, no longer written) -- ========================================= -- 1. Projects Table -- ========================================= CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL, description TEXT, color VARCHAR(7), -- hex color e.g. '#3B82F6' icon VARCHAR(50), -- emoji or icon name scope VARCHAR(20) NOT NULL DEFAULT 'personal' CHECK (scope IN ('personal', 'team', 'global')), owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, team_id UUID REFERENCES teams(id) ON DELETE SET NULL, is_archived BOOLEAN NOT NULL DEFAULT false, settings JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id); CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id) WHERE team_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope); 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(); COMMENT ON TABLE projects IS 'Organizes channels, KBs, and notes into named workspaces'; COMMENT ON COLUMN projects.scope IS 'personal=owner only, team=team members, global=all users'; -- ========================================= -- 2. Project ↔ Channel Junction -- ========================================= -- One project per channel (UNIQUE on channel_id). -- Source of truth for position and folder within the project. 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, position INTEGER NOT NULL DEFAULT 0, folder TEXT, -- sub-folder within the project added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, channel_id), UNIQUE (channel_id) -- one project per channel ); CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id); CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id); COMMENT ON TABLE project_channels IS 'Links channels to projects. UNIQUE(channel_id) enforces one-project-per-channel.'; COMMENT ON COLUMN project_channels.position IS 'Sort order within the project sidebar group'; COMMENT ON COLUMN project_channels.folder IS 'Optional sub-folder path within the project'; -- ========================================= -- 3. Project ↔ Knowledge Base Junction -- ========================================= CREATE TABLE IF NOT EXISTS project_knowledge_bases ( project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, auto_search BOOLEAN NOT NULL DEFAULT false, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, kb_id) ); CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id); CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id); COMMENT ON TABLE project_knowledge_bases IS 'Binds KBs to Projects — injected at completion time for all project channels'; COMMENT ON COLUMN project_knowledge_bases.auto_search IS 'true = auto-prepend top-K results; false = kb_search tool only'; -- ========================================= -- 4. Project ↔ Note Junction -- ========================================= CREATE TABLE IF NOT EXISTS project_notes ( project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, note_id) ); CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id); CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id); COMMENT ON TABLE project_notes IS 'Links notes to projects for organizational grouping'; -- ========================================= -- 5. Denormalized project_id on Channels -- ========================================= -- Fast sidebar query: SELECT ... FROM channels WHERE project_id = $1 -- Source of truth is project_channels; this is updated atomically. ALTER TABLE channels ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE project_id IS NOT NULL; -- ========================================= -- 6. Extend resource_grants CHECK -- ========================================= -- Add 'project' to allowed resource_type values. -- Postgres ALTER TABLE ... ALTER CONSTRAINT is not supported; drop + re-add. ALTER TABLE resource_grants DROP CONSTRAINT IF EXISTS resource_grants_resource_type_check; ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check CHECK (resource_type IN ('persona', 'knowledge_base', 'project')); -- ========================================= -- 7. Best-effort folder → project migration -- ========================================= -- Create a personal project for each distinct folder value, then -- associate the channels. Skip if no folder values exist. -- This is idempotent: re-running won't duplicate projects because -- we check for existing project_id. DO $$ DECLARE _folder TEXT; _user_id UUID; _proj_id UUID; _ch_id UUID; BEGIN FOR _user_id, _folder IN SELECT DISTINCT user_id, folder FROM channels WHERE folder IS NOT NULL AND folder != '' AND project_id IS NULL LOOP -- Create project named after the folder INSERT INTO projects (name, scope, owner_id) VALUES (_folder, 'personal', _user_id) RETURNING id INTO _proj_id; -- Associate channels FOR _ch_id IN SELECT id FROM channels WHERE user_id = _user_id AND folder = _folder AND project_id IS NULL LOOP INSERT INTO project_channels (project_id, channel_id, position) VALUES (_proj_id, _ch_id, 0) ON CONFLICT DO NOTHING; UPDATE channels SET project_id = _proj_id WHERE id = _ch_id; END LOOP; END LOOP; END $$;