112 lines
4.8 KiB
TypeScript
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;
|
|
}
|