// health.controller.ts import { Controller, Get, HttpStatus, Res } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { InjectDataSource } from "@nestjs/typeorm"; import { Public } from "@edr/api-common"; import { Response } from "express"; import { DataSource } from "typeorm"; import { MatrixClient } from "../chat/matrix.client"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; type CheckStatus = "ok" | "error" | "unknown"; /** * Readiness normally stays green when only the broker is down. * * A 503 pulls the pod out of the load balancer, which would take booking, * tracking and billing offline because SMS is unreachable — a strictly worse * outcome than degraded notifications. The broker check is therefore reported, * not enforced, and `READINESS_REQUIRES_BROKER=true` opts into hard-failing for * deployments where a silent OTP black hole is the greater risk. */ const READINESS_REQUIRES_BROKER = process.env.READINESS_REQUIRES_BROKER === "true"; @ApiTags("Health") @Controller("health") export class HealthController { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly smsClient: SmsClientService, private readonly emailClient: EmailClientService, private readonly matrix: MatrixClient, ) {} @Get() @Public() @ApiOperation({ summary: "Liveness probe" }) liveness() { return { status: "ok", timestamp: new Date().toISOString() }; } @Get("ready") @Public() @ApiOperation({ summary: "Readiness probe — database, SMS/email broker connectivity, and the Matrix admin token. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true; chat failures always report as degraded.", }) async readiness(@Res() res: Response) { const startedAt = Date.now(); let database: { status: CheckStatus; latencyMs: number; error?: string }; try { await this.dataSource.query("SELECT 1"); database = { status: "ok", latencyMs: Date.now() - startedAt }; } catch (error) { database = { status: "error", latencyMs: Date.now() - startedAt, error: error instanceof Error ? error.message : "Unknown error", }; } // `null` from the client means the connection manager was not reachable // through Nest's internals — surfaced as "unknown" so a shape change in // @nestjs/microservices degrades to honest ignorance, not a false "ok". const toStatus = (connected: boolean | null): CheckStatus => connected === null ? "unknown" : connected ? "ok" : "error"; const broker = { sms: { status: toStatus(this.smsClient.brokerConnected) }, email: { status: toStatus(this.emailClient.brokerConnected) }, // Every OTP, and every booking/billing notification, publishes through // these. `error` here means codes are being generated and silently dropped. enabled: process.env.RABBITMQ_ENABLED !== "false", }; const chat = await this.chatCheck(); const brokerDown = broker.sms.status === "error" || broker.email.status === "error"; const failed = database.status === "error" || (READINESS_REQUIRES_BROKER && brokerDown); const status = failed ? "error" : brokerDown || chat.status === "error" ? "degraded" : "ok"; return res .status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK) .json({ status, timestamp: new Date().toISOString(), checks: { database, broker, chat }, }); } /** * Chat provisioning runs entirely on MATRIX_ADMIN_TOKEN, and a token that is * valid but not *server admin* fails only the `/_synapse/admin` half: rooms * are never created, joins never happen, and the sole symptom is an empty * Element for every employee. Nothing else in the probe would catch that. * * Degraded, never a 503 — chat is not worth pulling the pod out of the load * balancer for, by the same reasoning as the broker check above. `unknown` * when MATRIX_ENABLED is off: a feature that is switched off is not a fault. */ private async chatCheck(): Promise<{ status: CheckStatus; enabled: boolean; actingAs?: string; error?: string; }> { if (!this.matrix.enabled) return { status: "unknown", enabled: false }; const check = await this.matrix.adminCheck(); return { status: check.ok ? "ok" : "error", enabled: true, actingAs: check.actingAs, error: check.error, }; } @Get("info") @Public() @ApiOperation({ summary: "App info — version, environment, uptime" }) info() { return { name: "edr-freight-api", version: process.env.npm_package_version ?? "1.0.0", environment: process.env.NODE_ENV ?? "development", uptimeSeconds: Math.floor(process.uptime()), timestamp: new Date().toISOString(), }; } }