import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Req, UseGuards } from "@nestjs/common"; import type { Request } from "express"; import type { PoolClient } from "pg"; import { parseId, requireSession } from "../http/validation.js"; import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; import { streamOutputTarget } from "@tactical-media-broadcast-hub/core"; import { LiveDestinationRepository } from "./live-destination.repository.js"; import type { OutputSummary, TenantTransaction } from "./live-destination.repository.js"; /** Creates/edits stream outputs (the StreamOutput half of the WP9 boundary). */ @Controller("channels/:channelId/outputs") @UseGuards(RbacGuard) export class StreamOutputController { constructor( @Inject(LiveDestinationRepository) private readonly destinations: LiveDestinationRepository, @Inject("STREAM_OUTPUT_TRANSACTION") private readonly transaction: TenantTransaction, ) {} @Post() @RequirePermission("streamoutput.manage") async create(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string, @Body() body: unknown): Promise { const session = requireSession(request); const parsed = parseId(channelId, "channelId"); const dto = parseCreateOutput(body); return this.transaction(session.workspaceId, async (client) => { await this.assertChannel(client, session.workspaceId, parsed); // Type + API validation of the aggregate boundary: exactly one target. const target = streamOutputTarget({ destinationId: dto.destinationId, credentialHandleId: dto.credentialHandleId }); if (target === null) throw new BadRequestException("output must reference a destination or its own credential handle"); if (target.kind === "destination") await this.destinations.get(session.workspaceId, target.destinationId); if (target.kind === "handle" && !/^vh_[A-Za-z0-9_-]{16,128}$/.test(target.credentialHandleId)) { throw new BadRequestException("credentialHandleId must be a vault handle reference"); } const result = await client.query( `INSERT INTO stream_outputs (workspace_id, virtual_channel_id, label, profile, mode, enabled, destination_id, credential_handle_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [session.workspaceId, parsed, dto.label, dto.profile, dto.mode, dto.enabled, dto.destinationId ?? null, dto.credentialHandleId ?? null], ); return mapOutput(result.rows[0] ?? {}); }); } @Get() @RequirePermission("streamoutput.manage") async list(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string): Promise { const session = requireSession(request); return this.transaction(session.workspaceId, async (client) => { await this.assertChannel(client, session.workspaceId, parseId(channelId, "channelId")); const result = await client.query( "SELECT * FROM stream_outputs WHERE workspace_id = $1 AND virtual_channel_id = $2 ORDER BY created_at", [session.workspaceId, channelId], ); return result.rows.map(mapOutput); }); } @Patch(":id") @RequirePermission("streamoutput.manage") async update(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string, @Param("id") id: string, @Body() body: unknown): Promise { const session = requireSession(request); const parsedChannel = parseId(channelId, "channelId"); const parsedId = parseId(id); const dto = parseUpdateOutput(body); return this.transaction(session.workspaceId, async (client) => { await this.assertChannel(client, session.workspaceId, parsedChannel); // Re-check the exclusivity invariant whenever a target field changes. if (dto.destinationId !== undefined || dto.credentialHandleId !== undefined) { const current = await client.query<{ destination_id: string | null; credential_handle_id: string | null }>( "SELECT destination_id, credential_handle_id FROM stream_outputs WHERE workspace_id=$1 AND virtual_channel_id=$2 AND id=$3", [session.workspaceId, parsedChannel, parsedId], ); const row = current.rows[0]; if (row === undefined) throw new BadRequestException("stream output not found"); const nextDestination = dto.destinationId === undefined ? row.destination_id ?? undefined : dto.destinationId ?? undefined; const nextHandle = dto.credentialHandleId === undefined ? row.credential_handle_id ?? undefined : dto.credentialHandleId ?? undefined; try { streamOutputTarget({ destinationId: nextDestination, credentialHandleId: nextHandle }); } catch { throw new BadRequestException("output cannot reference both a destination and its own credential handle"); } if (nextDestination !== undefined) await this.destinations.get(session.workspaceId, nextDestination); } const result = await client.query( `UPDATE stream_outputs SET label = COALESCE($3, label), enabled = COALESCE($4, enabled), profile = COALESCE($5, profile), mode = COALESCE($6, mode), destination_id = CASE WHEN $7 THEN $8 ELSE destination_id END, credential_handle_id = CASE WHEN $9 THEN $10 ELSE credential_handle_id END, updated_at = now() WHERE workspace_id = $1 AND virtual_channel_id = $2 AND id = $11 RETURNING *`, [session.workspaceId, parsedChannel, dto.label ?? null, dto.enabled ?? null, dto.profile ?? null, dto.mode ?? null, dto.destinationId !== undefined, dto.destinationId ?? null, dto.credentialHandleId !== undefined, dto.credentialHandleId ?? null, parsedId], ); if (result.rows[0] === undefined) throw new BadRequestException("stream output not found"); return mapOutput(result.rows[0]); }); } @Delete(":id") @RequirePermission("streamoutput.manage") async remove(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string, @Param("id") id: string): Promise<{ id: string }> { const session = requireSession(request); const parsedId = parseId(id); await this.transaction(session.workspaceId, async (client) => { await this.assertChannel(client, session.workspaceId, parseId(channelId, "channelId")); await client.query("DELETE FROM stream_outputs WHERE workspace_id=$1 AND virtual_channel_id=$2 AND id=$3", [session.workspaceId, channelId, parsedId]); }); return { id: parsedId }; } private async assertChannel(client: PoolClient, workspaceId: string, channelId: string): Promise { const found = await client.query("SELECT id FROM virtual_channels WHERE workspace_id=$1 AND id=$2", [workspaceId, channelId]); if (found.rowCount === 0) throw new BadRequestException("channelId must reference a virtual channel in this workspace"); } } export function mapOutput(row: Record): OutputSummary { return { id: String(row.id), workspaceId: String(row.workspace_id), virtualChannelId: String(row.virtual_channel_id), label: String(row.label), profile: String(row.profile), mode: String(row.mode), enabled: Boolean(row.enabled), destinationId: row.destination_id === null ? null : String(row.destination_id), credentialHandleId: row.credential_handle_id === null ? null : String(row.credential_handle_id), createdAt: new Date(row.created_at as Date), updatedAt: new Date(row.updated_at as Date), }; } const LABEL = /^.{1,120}$/s; export function parseCreateOutput(value: unknown): { label: string; profile: string; mode: string; enabled: boolean; destinationId?: string; credentialHandleId?: string } { if (typeof value !== "object" || value === null) throw new BadRequestException("body must be an object"); const body = value as Record; const label = typeof body.label === "string" ? body.label.trim() : ""; if (!LABEL.test(label)) throw new BadRequestException("label must be 1-120 characters"); const profile = body.profile === undefined ? "720p30" : String(body.profile); if (!["720p30", "1080p30", "1080p60"].includes(profile)) throw new BadRequestException("profile must be 720p30, 1080p30 or 1080p60"); const mode = body.mode === undefined ? "passthrough" : String(body.mode); if (!["passthrough", "normalize"].includes(mode)) throw new BadRequestException("mode must be passthrough or normalize"); const enabled = body.enabled === undefined ? true : Boolean(body.enabled); const dto: { label: string; profile: string; mode: string; enabled: boolean; destinationId?: string; credentialHandleId?: string } = { label, profile, mode, enabled }; if (body.destinationId !== undefined && body.destinationId !== null) dto.destinationId = String(body.destinationId); if (body.credentialHandleId !== undefined && body.credentialHandleId !== null) dto.credentialHandleId = String(body.credentialHandleId); return dto; } export function parseUpdateOutput(value: unknown): { label?: string; profile?: string; mode?: string; enabled?: boolean; destinationId?: string | null; credentialHandleId?: string | null } { if (typeof value !== "object" || value === null) throw new BadRequestException("body must be an object"); const body = value as Record; const dto: { label?: string; profile?: string; mode?: string; enabled?: boolean; destinationId?: string | null; credentialHandleId?: string | null } = {}; if (body.label !== undefined) { const label = typeof body.label === "string" ? body.label.trim() : ""; if (!LABEL.test(label)) throw new BadRequestException("label must be 1-120 characters"); dto.label = label; } if (body.profile !== undefined && !["720p30", "1080p30", "1080p60"].includes(String(body.profile))) throw new BadRequestException("profile must be 720p30, 1080p30 or 1080p60"); if (body.profile !== undefined) dto.profile = String(body.profile); if (body.mode !== undefined && !["passthrough", "normalize"].includes(String(body.mode))) throw new BadRequestException("mode must be passthrough or normalize"); if (body.mode !== undefined) dto.mode = String(body.mode); if (body.enabled !== undefined) dto.enabled = Boolean(body.enabled); if (body.destinationId !== undefined) dto.destinationId = body.destinationId === null ? null : String(body.destinationId); if (body.credentialHandleId !== undefined) dto.credentialHandleId = body.credentialHandleId === null ? null : String(body.credentialHandleId); return dto; }