T-HUB/apps/api/src/live-chat/live-chat.controller.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

169 lines
8.5 KiB
TypeScript

import { BadRequestException, Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, Post, Query, Req, UseGuards, NotFoundException, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { idempotentChat } from "./idempotency.js";
import { withTenant } from "../db/client.js";
import { appendTenantAudit } from "../audit/audit.service.js";
import { DEVICE_SCOPES } from "@tactical-media-broadcast-hub/core";
import { parseId, parseLimit, requireObject, requireSession, requireString } from "../http/validation.js";
import { RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { LiveChatGuard, SessionOnly } from "./live-chat.guard.js";
import { tokenWorkspace } from "./auth.js";
import { DeviceService } from "./devices.js";
import { LiveChatHubService } from "./live-chat.service.js";
/**
* §3.5 endpoints. No `/workspaces/{wid}` prefix — the workspace comes from the
* session (or device token) per repo convention.
*/
@Controller()
export class LiveChatController {
constructor(@Inject(LiveChatHubService) private readonly hub: LiveChatHubService, @Inject(DeviceService) private readonly devices: DeviceService) {}
@Get("live-chat/lives")
@UseGuards(LiveChatGuard)
@RequirePermission("stream.view")
async lives(@Req() request: Request & AuthenticatedRequest) {
return this.hub.lives(requireSession(request).workspaceId);
}
@Get("live-chat/lives/:channelId/messages")
@UseGuards(LiveChatGuard)
@RequirePermission("livechat.view")
async messages(
@Req() request: Request & AuthenticatedRequest,
@Param("channelId") channelId: string,
@Query("after") after: string | undefined,
@Query("limit") limit: string | undefined,
@Query("platform") platform: string | undefined,
) {
const session = requireSession(request);
return this.hub.messages(session.workspaceId, parseId(channelId, "channelId"), {
limit: parseLimit(limit, 100, 500),
...(after === undefined || after === "" ? {} : { after: parseSeq(after) }),
...(platform === undefined || platform === "" ? {} : { provider: platform }),
});
}
@Post("live-chat/lives/:channelId/messages")
@UseGuards(LiveChatGuard)
@RequirePermission("livechat.send")
@HttpCode(202)
async send(
@Req() request: Request & AuthenticatedRequest,
@Param("channelId") channelId: string,
@Body() body: unknown,
@Headers("idempotency-key") key?: string,
) {
const session = requireSession(request);
const parsed = requireObject(body, "body");
const text = requireString(parsed.text, "text");
if (text.length > 500) throw new BadRequestException("text must be 1..500 characters");
const targets = parseTargets(parsed.targets);
const liveId = parseId(channelId, "channelId");
const replyTo = parsed.replyTo === undefined ? undefined : parseId(requireString(parsed.replyTo, "replyTo"), "replyTo");
return idempotentChat(withTenant, session.workspaceId, session.userId, `send:${channelId}`, key, body, async () => {
const outcome = await this.hub.send(session.workspaceId, liveId, {
text, targets, ...(replyTo === undefined ? {} : { replyTo }),
author: { handle: `${session.role.toLowerCase()}:${session.userId.slice(0, 8)}` },
});
return { message: outcome.message, delivery: outcome.delivery, ...(outcome.refused ? { refused: outcome.refused } : {}) };
});
}
@Post("live-chat/lives/:channelId/messages/:messageId/moderation")
@UseGuards(LiveChatGuard)
@RequirePermission("livechat.moderate")
@HttpCode(202)
async moderate(
@Req() request: Request & AuthenticatedRequest,
@Param("channelId") channelId: string,
@Param("messageId") messageId: string,
@Body() body: unknown,
@Headers("idempotency-key") key?: string,
) {
const session = requireSession(request);
const parsed = requireObject(body, "body");
const action = parsed.action;
if (action !== "delete" && action !== "timeout" && action !== "ban") throw new BadRequestException("action must be delete, timeout or ban");
let durationSeconds: number | undefined;
if (parsed.durationSeconds !== undefined) {
if (typeof parsed.durationSeconds !== "number") throw new BadRequestException("durationSeconds must be a number");
durationSeconds = parsed.durationSeconds;
if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 1209600) throw new BadRequestException("durationSeconds must be 1..1209600");
}
if (action === "timeout" && durationSeconds === undefined) throw new BadRequestException("timeout requires durationSeconds");
return idempotentChat(withTenant, session.workspaceId, session.userId, `moderate:${channelId}:${messageId}`, key, body, async () => {
const outcome = await this.hub.moderate(session.workspaceId, parseId(channelId, "channelId"), {
action,
...(durationSeconds === undefined ? {} : { durationSeconds }),
message: await this.hubTarget(session.workspaceId, parseId(channelId, "channelId"), parseId(messageId, "messageId")),
});
await appendTenantAudit(session.workspaceId, { actorUserId: session.userId, actorRole: session.role, action: `livechat.${action}`,
targetType: "live_chat_message", targetId: messageId, diff: { delivery: outcome.delivery, durationSeconds } });
return { delivery: outcome.delivery };
});
}
@Get("live-chat/lives/:channelId/capabilities")
@UseGuards(LiveChatGuard)
@RequirePermission("livechat.view")
async capabilities(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string) {
const session = requireSession(request);
return this.hub.capabilities(session.workspaceId, parseId(channelId, "channelId"));
}
// -- Device pairing (§3.4) ------------------------------------------------
@Post("devices/pairings")
@SessionOnly()
@UseGuards(LiveChatGuard)
@RequirePermission("livechat.send")
async issuePairing(@Req() request: Request & AuthenticatedRequest, @Headers("idempotency-key") key?: string): Promise<{ pairingCode: string; qrPayload: string; expiresAt: string }> {
const session = requireSession(request);
return idempotentChat(withTenant, session.workspaceId, session.userId, "pairing", key, {}, () => this.devices.issuePairing(session.workspaceId, session.userId, DEVICE_SCOPES));
}
@Post("devices/pairings/redeem")
@HttpCode(200)
async redeem(@Body() body: unknown, @Headers("idempotency-key") key?: string, @Headers("authorization") auth?: string): Promise<{ deviceToken: string; workspaceId: string }> {
const parsed = requireObject(body, "body");
const pairingCode = requireString(parsed.pairingCode, "pairingCode");
const deviceName = requireString(parsed.deviceName, "deviceName");
if (deviceName.length > 120) throw new BadRequestException("deviceName too long");
// The bearer pairing code resolves its issuing workspace; no session is needed.
const workspace = auth?.startsWith("Bearer ") ? tokenWorkspace(auth.slice(7)) : undefined;
if (auth && !workspace) throw new UnauthorizedException();
return this.devices.redeemCode(pairingCode, deviceName, key, workspace ?? undefined);
}
@Delete("devices/:deviceId")
@UseGuards(LiveChatGuard)
@RequirePermission("livechat.send")
async revoke(@Req() request: Request & AuthenticatedRequest, @Param("deviceId") deviceId: string) {
const session = requireSession(request);
await this.devices.revoke(session.workspaceId, parseId(deviceId, "deviceId"));
return { revoked: true };
}
private async hubTarget(workspaceId: string, channelId: string, messageId: string) {
const found = await this.hub.findMessage(workspaceId, channelId, messageId);
if (!found) throw new NotFoundException("message not found");
return { id: found.id, connectionId: found.connectionId, provider: found.provider, providerMessageId: found.providerMessageId, authorHandle: found.author.handle };
}
}
function parseSeq(value: string): number {
const n = Number(value);
if (!Number.isSafeInteger(n) || n < 0) throw new BadRequestException("after must be a non-negative integer seq");
return n;
}
function parseTargets(value: unknown): "all" | string[] {
if (value === "all") return "all";
if (!Array.isArray(value) || value.length === 0 || value.length > 32) throw new BadRequestException('targets must be "all" or 1..32 connection ids');
return value.map((entry): string => {
if (typeof entry !== "string") throw new BadRequestException("targets must contain connection ids");
return parseId(entry, "connectionId");
});
}