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 { 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), ); } /** * Count of collectives in MY workspace (roots only) — the post-login router: * zero => the app shows the "no collectives yet" home menu (create / join / * solo tools) instead of the streams wall. Cheaper and more stable than the * full tree for a boolean decision; same permission as the tree read. */ @Get("mine/count") @RequirePermission("workspace.view") async myCount(@Req() request: AuthedRequest): Promise<{ collectives: number; workspaceKind: "shared" | "personal" }> { return this.collectives.myCollectiveCount(requireSession(request).workspaceId); } /** The workspace's collective tree: collectives as roots, subcollectives nested. */ @Get("tree") @RequirePermission("workspace.view") async tree(@Req() request: AuthedRequest): Promise { 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 { 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; }