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
This commit is contained in:
parent
c31762b82d
commit
4a7e78b28d
23 changed files with 2892 additions and 8 deletions
131
apps/api/migrations/030_collectives.sql
Normal file
131
apps/api/migrations/030_collectives.sql
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
-- 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue