- 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
131 lines
7.3 KiB
PL/PgSQL
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;
|