diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index f467d4f..d3cb8dd 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,68 +1,15 @@ import { NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { redis } from '@/lib/redis'; -import { minioClient } from '@/lib/minio'; -import { env } from '@/lib/env'; -import { sql } from 'drizzle-orm'; -type CheckStatus = 'ok' | 'error'; - -interface HealthChecks { - postgres: CheckStatus; - redis: CheckStatus; - minio: CheckStatus; -} - -interface HealthResponse { - status: 'healthy' | 'degraded'; - checks: HealthChecks; - timestamp: string; -} - -export async function GET(): Promise> { - const checks: HealthChecks = { - postgres: 'error', - redis: 'error', - minio: 'error', - }; - - await Promise.allSettled([ - db - .execute(sql`SELECT 1`) - .then(() => { - checks.postgres = 'ok'; - }) - .catch(() => { - checks.postgres = 'error'; - }), - - redis - .ping() - .then(() => { - checks.redis = 'ok'; - }) - .catch(() => { - checks.redis = 'error'; - }), - - minioClient - .bucketExists(env.MINIO_BUCKET) - .then(() => { - checks.minio = 'ok'; - }) - .catch(() => { - checks.minio = 'error'; - }), - ]); - - const allHealthy = Object.values(checks).every((s) => s === 'ok'); - const status: HealthResponse['status'] = allHealthy ? 'healthy' : 'degraded'; - - const body: HealthResponse = { - status, - checks, - timestamp: new Date().toISOString(), - }; - - return NextResponse.json(body, { status: allHealthy ? 200 : 503 }); +/** + * Liveness probe — confirms the Next.js process is responding. + * + * Returns 200 unconditionally; if the process is wedged or has crashed + * the request never lands here at all. Do NOT include database/Redis/MinIO + * checks in this endpoint — a transient downstream blip should drop the + * pod from the load balancer (readiness), not restart the pod (liveness). + * + * For deep dependency checks, hit `/api/ready` instead. + */ +export async function GET() { + return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }); } diff --git a/src/app/api/ready/route.ts b/src/app/api/ready/route.ts new file mode 100644 index 0000000..569b65e --- /dev/null +++ b/src/app/api/ready/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server'; +import { sql } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { redis } from '@/lib/redis'; +import { minioClient } from '@/lib/minio'; +import { env } from '@/lib/env'; + +type CheckStatus = 'ok' | 'error'; + +interface ReadyChecks { + postgres: CheckStatus; + redis: CheckStatus; + minio: CheckStatus; +} + +interface ReadyResponse { + status: 'ready' | 'degraded'; + checks: ReadyChecks; + timestamp: string; +} + +/** + * Readiness probe — verifies that every backing service this process + * needs to serve traffic is reachable. A 503 should drop the pod from the + * load balancer until the next probe succeeds; it should not trigger a + * pod restart (that's what `/api/health` is for). + * + * Checks: + * - postgres: `SELECT 1` against the primary + * - redis: `PING` + * - minio: `bucketExists()` + * + * Documenso + SMTP are intentionally not probed here: they're optional + * integrations, and each tenant configures its own credentials. A + * tenant-misconfigured Documenso instance shouldn't deadline the entire + * shared CRM. + */ +export async function GET(): Promise> { + const checks: ReadyChecks = { + postgres: 'error', + redis: 'error', + minio: 'error', + }; + + await Promise.allSettled([ + db + .execute(sql`SELECT 1`) + .then(() => { + checks.postgres = 'ok'; + }) + .catch(() => { + checks.postgres = 'error'; + }), + + redis + .ping() + .then(() => { + checks.redis = 'ok'; + }) + .catch(() => { + checks.redis = 'error'; + }), + + minioClient + .bucketExists(env.MINIO_BUCKET) + .then(() => { + checks.minio = 'ok'; + }) + .catch(() => { + checks.minio = 'error'; + }), + ]); + + const allReady = Object.values(checks).every((s) => s === 'ok'); + const status: ReadyResponse['status'] = allReady ? 'ready' : 'degraded'; + + return NextResponse.json( + { status, checks, timestamp: new Date().toISOString() }, + { status: allReady ? 200 : 503 }, + ); +} diff --git a/src/lib/services/gdpr-bundle-builder.ts b/src/lib/services/gdpr-bundle-builder.ts index a49bbf9..12a0720 100644 --- a/src/lib/services/gdpr-bundle-builder.ts +++ b/src/lib/services/gdpr-bundle-builder.ts @@ -145,25 +145,40 @@ export async function buildClientBundle(clientId: string, portId: string): Promi clientId, schemaVersion: 1, }, - client: client as unknown as Record, - contacts: contacts as unknown as Record[], - addresses: addresses as unknown as Record[], + // Drizzle row types contain non-`unknown` value types (Date, branded + // strings). The bundle is exported as JSON, so we widen to plain + // `Record` here. `toJsonRow` performs the narrow → wide + // widening in a single, locally-typed step instead of the prior + // `as unknown as Record` double-cast. + client: toJsonRow(client), + contacts: contacts.map(toJsonRow), + addresses: addresses.map(toJsonRow), tags: tagJoins, - relationships: relationships as unknown as Record[], - notes: notes as unknown as Record[], - ownedYachts: ownedYachts as unknown as Record[], - companyMemberships: membershipRows as unknown as Array<{ - membership: Record; - company: Record; - }>, - interests: interestRows as unknown as Record[], - reservations: reservationRows as unknown as Record[], - invoices: invoiceRows as unknown as Record[], - documents: documentRows as unknown as Record[], - auditTrail: auditRows as unknown as Record[], + relationships: relationships.map(toJsonRow), + notes: notes.map(toJsonRow), + ownedYachts: ownedYachts.map(toJsonRow), + companyMemberships: membershipRows.map((row) => ({ + membership: toJsonRow(row.membership), + company: toJsonRow(row.company), + })), + interests: interestRows.map(toJsonRow), + reservations: reservationRows.map(toJsonRow), + invoices: invoiceRows.map(toJsonRow), + documents: documentRows.map(toJsonRow), + auditTrail: auditRows.map(toJsonRow), }; } +/** + * Widens a Drizzle row type to `Record` for inclusion in + * the JSON bundle. Drizzle row types are narrower than the open record + * shape we want; this helper does the widening in one place rather than + * scattering double-casts across the call sites. + */ +function toJsonRow(row: T): Record { + return row as Record; +} + // ─── HTML rendering ────────────────────────────────────────────────────────── function escapeHtml(s: unknown): string { @@ -217,7 +232,7 @@ export function renderBundleHtml(bundle: GdprBundle): string { tableSection('Client', [bundle.client]), tableSection('Contacts', bundle.contacts), tableSection('Addresses', bundle.addresses), - tableSection('Tags', bundle.tags as unknown as Record[]), + tableSection('Tags', bundle.tags.map(toJsonRow)), tableSection('Relationships', bundle.relationships), tableSection('Notes', bundle.notes), tableSection('Owned yachts', bundle.ownedYachts), diff --git a/tests/integration/health-and-ready.test.ts b/tests/integration/health-and-ready.test.ts new file mode 100644 index 0000000..993ffb6 --- /dev/null +++ b/tests/integration/health-and-ready.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; + +import { GET as healthGet } from '@/app/api/health/route'; +import { GET as readyGet } from '@/app/api/ready/route'; + +describe('GET /api/health (liveness)', () => { + it('returns 200 + status=ok regardless of downstream dependency state', async () => { + const res = await healthGet(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('ok'); + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe('GET /api/ready (readiness)', () => { + it('returns ready=200 when postgres + redis + minio all answer', async () => { + // The dev/test environment has all three reachable; this covers the + // happy path. The degraded path is not exercised here because + // simulating a down dep without leaking into other tests is awkward; + // the route's logic is intentionally trivial (Promise.allSettled + + // every-ok check) and worth covering at the unit level only. + const res = await readyGet(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('ready'); + expect(body.checks).toEqual({ + postgres: 'ok', + redis: 'ok', + minio: 'ok', + }); + }); +});