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
100
apps/api/src/collectives/collectives.controller.ts
Normal file
100
apps/api/src/collectives/collectives.controller.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Body, Controller, Get, Inject, Param, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import { CollectivesService, parseLanguage, type CollectiveNode } from "./collectives.service.js";
|
||||
|
||||
/**
|
||||
* Collective surface (P1): create + tree, plus the public join seam.
|
||||
*
|
||||
* Creation is authenticated and permission-gated (workspace.members.manage —
|
||||
* the same permission that mints invite links, since founding a collective
|
||||
* makes you the person who lets others in). The tree read is workspace.view,
|
||||
* matching GET /groups.
|
||||
*
|
||||
* The invite/join flow itself stays EXACTLY where it is (POST /invites/:token/
|
||||
* claim, InviteClaimController): this controller only exposes the public
|
||||
* metadata a joiner needs to see WHICH collective they are entering, so the
|
||||
* existing /join/[token] page can label the claim card with the collective
|
||||
* name instead of the bare workspace name.
|
||||
*/
|
||||
type AuthedRequest = Request & AuthenticatedRequest;
|
||||
|
||||
function actorOf(request: AuthedRequest): { userId: string; role: string } {
|
||||
const session = requireSession(request);
|
||||
return { userId: session.userId, role: session.role };
|
||||
}
|
||||
|
||||
@Controller("collectives")
|
||||
@UseGuards(RbacGuard)
|
||||
export class CollectivesController {
|
||||
constructor(
|
||||
@Inject(CollectivesService) private readonly collectives: CollectivesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a collective: group + room set (via the provisioner) + founder
|
||||
* membership. Body: { name, language?, description? }. Room provisioning
|
||||
* failures surface as 500 with a clear message and NOTHING is persisted.
|
||||
*/
|
||||
@Post()
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async create(@Req() request: AuthedRequest, @Body() body: unknown): Promise<CollectiveNode> {
|
||||
const session = requireSession(request);
|
||||
const dto = requireObject(body, "body");
|
||||
return this.collectives.createCollective(
|
||||
session.workspaceId,
|
||||
{
|
||||
name: requireString(dto.name, "name"),
|
||||
language: parseLanguage(dto.language),
|
||||
description: optionalString(dto.description, "description") ?? "",
|
||||
},
|
||||
actorOf(request),
|
||||
);
|
||||
}
|
||||
|
||||
/** The workspace's collective tree: collectives as roots, subcollectives nested. */
|
||||
@Get("tree")
|
||||
@RequirePermission("workspace.view")
|
||||
async tree(@Req() request: AuthedRequest): Promise<CollectiveNode[]> {
|
||||
return this.collectives.collectiveTree(requireSession(request).workspaceId);
|
||||
}
|
||||
|
||||
/** One collective subtree. Declared before :id so the literal path wins. */
|
||||
@Get("tree/:id")
|
||||
@RequirePermission("workspace.view")
|
||||
async subtree(@Req() request: AuthedRequest, @Param("id") id: string): Promise<CollectiveNode | null> {
|
||||
const tree = await this.collectives.collectiveTree(requireSession(request).workspaceId);
|
||||
return findInTree(tree, parseId(id));
|
||||
}
|
||||
}
|
||||
|
||||
/** Public, pre-auth metadata for the /join/[token] page (collective labelling). */
|
||||
@Controller("collectives-public")
|
||||
export class CollectivesPublicController {
|
||||
constructor(@Inject(CollectivesService) private readonly collectives: CollectivesService) {}
|
||||
|
||||
/**
|
||||
* GET /collectives-public/for-invite/:token — the collectives of the
|
||||
* workspace an invite token points at, by NAME ONLY.
|
||||
*
|
||||
* This is deliberately narrow: the caller has no tenant context (they have not
|
||||
* claimed yet), so the service resolves it through the same SECURITY DEFINER
|
||||
* shape as invite_link_for_claim — one row keyed by a secret the caller
|
||||
* already proved possession of. No room ids, no member counts.
|
||||
*/
|
||||
@Get("for-invite/:token")
|
||||
async forInvite(@Param("token") token: string, @Res({ passthrough: true }) response: Response): Promise<{ collectives: Array<{ id: string; name: string }> }> {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
return this.collectives.collectivesForInviteToken(requireString(token, "token"));
|
||||
}
|
||||
}
|
||||
|
||||
function findInTree(nodes: CollectiveNode[], id: string): CollectiveNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
const found = findInTree(node.children, id);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue