T-HUB/apps/api/src/collectives/collectives.controller.ts

112 lines
4.8 KiB
TypeScript

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),
);
}
/**
* 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<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;
}