-- ========================================== -- Chat Switchboard — 010 Projects -- ========================================== -- Projects, junction tables, and cross-domain FK columns on -- channels/projects for workspace/project bindings. -- ICD §8 (Projects) -- ========================================== -- ========================================= -- PROJECTS -- ========================================= CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL, description TEXT, color VARCHAR(7), icon VARCHAR(50), 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, workspace_id UUID REFERENCES workspaces(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); CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id) WHERE workspace_id IS NOT NULL; 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'; -- ========================================= -- PROJECT ↔ CHANNEL JUNCTION -- ========================================= 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, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, channel_id), UNIQUE (channel_id) ); 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.'; -- ========================================= -- PROJECT ↔ KB 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); -- ========================================= -- 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); -- ========================================= -- CROSS-DOMAIN FK COLUMNS -- ========================================= -- Denormalized project_id + workspace_id on channels. ALTER TABLE channels ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL; 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; -- ========================================= -- FOLDER → PROJECT MIGRATION (idempotent) -- ========================================= -- Best-effort migration of legacy folder values into projects. -- Only runs if there are channels with a folder value and no 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 INSERT INTO projects (name, scope, owner_id) VALUES (_folder, 'personal', _user_id) RETURNING id INTO _proj_id; 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 $$;