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"); }); }