T-HUB/apps/api/migrations/030_collectives.sql
glass beadian 4a7e78b28d Collectives P1: participation landing (ES/EN), group tree, room provisioning APIs
- 030: groups.parent_id tree + kind discriminator + group_rooms mapping
- /collectives module: create/tree/join APIs, MatrixProvisioner (open room
  public+federated, closed room E2EE-at-creation, onboarding room), NullProvisioner
  fail-soft when ARCHIVE_PROVISIONER_* unset
- web root becomes the public participation manual (ES-first + EN toggle,
  three paths: join by invite, start collective, solo account)
- participation manual content in core
2026-10-12 13:53:40 +00:00

131 lines
7.3 KiB
PL/PgSQL

-- 030: collectives — group tree (P1 of the collectives architecture,
-- docs/design/2026-10-12-collectives-architecture.md §"Collective model").
--
-- A COLLECTIVE IS A GROUP in the Hub's group tree:
-- * groups.parent_id becomes self-referencing (top-level groups = collectives,
-- children = subcollectives, recursive);
-- * groups.kind is the minimal discriminator ('collective' | 'subcollective' |
-- 'plain') so the tree query never has to infer structure from parent_id
-- alone — a legacy sharing-scope group (008) and a collective are both
-- parentless, and 'plain' keeps the existing semantics untouched;
-- * group_rooms maps each collective/subcollective to its room set
-- (open | closed | onboarding), extending the archive mapping pattern
-- (ROOM_MAPPINGS / chat_media_archive) into a durable, tenant-scoped table.
--
-- This deliberately rides the existing groups/RBAC/files machinery
-- (group_files, group_members, chat_media_archive are keyed workspace+group)
-- instead of introducing cross-tenant writes.
--
-- ON DELETE RESTRICT on groups.parent_id (not CASCADE), on purpose:
-- * Every other child table of groups (group_members, group_connections,
-- group_assets, chat_media_archive, group_rooms) CASCADES, so deleting a
-- group already tears down its own members/assets/rooms rows correctly.
-- * A collective with subcollectives is a TREE ROOT whose children are
-- independent units with their own room sets and their own files folders.
-- CASCADE would delete a whole subtree on a single root delete — silently
-- removing sibling subcollectives' chat archives and room mappings that the
-- operator never named. The audit log would then record one delete while
-- the tree lost N groups.
-- * RESTRICT forces the explicit, audited order the product wants: delete (or
-- re-parent) the subcollectives first, then the collective. Deleting a
-- subcollective still cascades its own rooms/members exactly like today.
-- * The FK is tenant-pinned via the existing composite (id, workspace_id), so
-- RESTRICT can never be a cross-tenant coupling: a subcollective cannot
-- reference a parent in another workspace even if ids collided.
--
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the 001/008/029
-- conventions: no app.workspace_id => NULL => no rows; FORCE binds sp_app too.
BEGIN;
-- ---------------------------------------------------------------------------
-- groups.parent_id — self-reference, tenant-pinned, RESTRICT
-- ---------------------------------------------------------------------------
-- NULL = top-level (a collective or a legacy plain group). Tenant-pinned by the
-- composite FK so the tree can never span workspaces, even transiently.
ALTER TABLE groups ADD COLUMN parent_id uuid;
ALTER TABLE groups
ADD CONSTRAINT groups_parent_fk
FOREIGN KEY (parent_id, workspace_id) REFERENCES groups(id, workspace_id)
ON DELETE RESTRICT ON UPDATE RESTRICT;
-- Subcollective lookup (children of a collective) — the only parent->children
-- access path P1 needs (the tree read walks parent_id in the other direction).
CREATE INDEX groups_parent_idx ON groups (parent_id, workspace_id);
-- No cycle guard at the DB level in P1: a cycle needs two UPDATEs of rows the
-- API never writes directly (the service creates roots and one-level children
-- only, and rejects a parent that is not itself a collective or subcollective).
-- A trigger walking an unbounded graph inside the mutating transaction would
-- burn locks for a shape the API layer structurally prevents; revisit if/when
-- re-parenting becomes a product surface.
-- ---------------------------------------------------------------------------
-- groups.kind — minimal discriminator
-- ---------------------------------------------------------------------------
-- 'plain' is the DEFAULT and is exactly the 008 semantics: a sharing scope for
-- connections/assets with no collective meaning. Existing rows are backfilled
-- explicitly (batch would be pointless: the table is small and the migration is
-- already inside one transaction).
-- Two named constraints so the backstop below can be added without guessing a
-- PostgreSQL auto-generated name.
ALTER TABLE groups ADD COLUMN kind text NOT NULL DEFAULT 'plain';
ALTER TABLE groups ADD CONSTRAINT groups_kind_values CHECK (kind IN ('collective','subcollective','plain'));
-- A subcollective must actually be a child; a collective must be a root. Kept
-- as a DB backstop so a bad write can never split kind from parent_id — the
-- typed validation lives in the API service (collectives.service.ts).
ALTER TABLE groups ADD CONSTRAINT groups_kind_shape CHECK (
(kind = 'subcollective' AND parent_id IS NOT NULL)
OR (kind IN ('collective','plain') AND parent_id IS NULL)
);
-- ---------------------------------------------------------------------------
-- group_rooms — the immutable group ↔ room mapping
-- ---------------------------------------------------------------------------
-- Extends the ROOM_MAPPINGS pattern (archive bot / chat_media_archive): room
-- IDs only, never aliases (`!opaque:server`), because a mapping must stay
-- resolvable. room_type is one of the three product room kinds. One row per
-- (group, room_type): a collective has exactly one open, one closed room; a
-- user's onboarding room is keyed to the group it onboards into. room_id is
-- UNIQUE across the table: a Matrix room belongs to exactly one slot in one
-- workspace, so a mis-provisioned duplicate cannot be silently re-pointed.
CREATE TABLE group_rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
group_id uuid NOT NULL,
-- 'open' (public+federated, never E2EE) | 'closed' (invite-only, E2EE at
-- birth, local only) | 'onboarding' (invite-only, disposable, bot-posted).
room_type text NOT NULL CHECK (room_type IN ('open','closed','onboarding')),
-- Matrix room ID, never an alias — same immutability rule as ROOM_MAPPINGS.
room_id text NOT NULL CHECK (room_id ~ '^![^:\s]+:[^\s]+$'),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT group_rooms_ws_group FOREIGN KEY (group_id, workspace_id)
REFERENCES groups(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Exactly one room of each type per group.
CONSTRAINT group_rooms_type_unique UNIQUE (group_id, room_type),
-- A room is mapped to exactly one (workspace, group, slot).
CONSTRAINT group_rooms_room_unique UNIQUE (workspace_id, room_id)
);
CREATE TRIGGER trg_group_rooms_ws_immutable BEFORE UPDATE ON group_rooms
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE group_rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE group_rooms FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON group_rooms
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- Room address lookup per collective (tree read joins on this), and the
-- reverse "which group does this room belong to" lookup the archive bot path
-- needs once mappings move out of env config (P2).
CREATE INDEX group_rooms_group_idx ON group_rooms (group_id, room_type);
CREATE INDEX group_rooms_room_idx ON group_rooms (room_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON group_rooms TO sp_app;
COMMIT;