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/.
169 lines
8.5 KiB
TypeScript
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");
|
|
});
|
|
}
|
|
|