chore(ops): split /api/health (liveness) from /api/ready (readiness)

Previously /api/health did deep dependency probes (postgres + redis +
minio) and 503'd on any failure. That's readiness behavior, not
liveness — a transient Redis/MinIO blip would tell the orchestrator to
restart the pod when it should only be dropped from the load balancer.

Make /api/health a thin liveness check (returns 200 unconditionally if
the process is responding) and move the deep checks to a new
/api/ready endpoint with the canonical Kubernetes-style 200/503
contract. Docker-compose healthchecks keep pointing at /api/health,
which is now more conservative (no false-positive container restarts).

Documenso/SMTP are intentionally not probed in /api/ready: each tenant
configures its own credentials and a tenant misconfiguration shouldn't
deadline the entire shared CRM.

Also tighten the gdpr-bundle-builder casts: replace the scattered
`as unknown as Record<string, unknown>` double-casts with a small
`toJsonRow<T>()` helper that does the widen narrow→wide in one place
with one cast hop instead of two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-29 02:03:10 +02:00
parent 7f9d90ad05
commit 61e40b5e76
4 changed files with 158 additions and 81 deletions

View File

@@ -1,68 +1,15 @@
import { NextResponse } from 'next/server'; 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'; /**
* Liveness probe — confirms the Next.js process is responding.
interface HealthChecks { *
postgres: CheckStatus; * Returns 200 unconditionally; if the process is wedged or has crashed
redis: CheckStatus; * the request never lands here at all. Do NOT include database/Redis/MinIO
minio: CheckStatus; * checks in this endpoint — a transient downstream blip should drop the
} * pod from the load balancer (readiness), not restart the pod (liveness).
*
interface HealthResponse { * For deep dependency checks, hit `/api/ready` instead.
status: 'healthy' | 'degraded'; */
checks: HealthChecks; export async function GET() {
timestamp: string; return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
}
export async function GET(): Promise<NextResponse<HealthResponse>> {
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 });
} }

View File

@@ -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(<configured-bucket>)`
*
* 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<NextResponse<ReadyResponse>> {
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 },
);
}

View File

@@ -145,25 +145,40 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
clientId, clientId,
schemaVersion: 1, schemaVersion: 1,
}, },
client: client as unknown as Record<string, unknown>, // Drizzle row types contain non-`unknown` value types (Date, branded
contacts: contacts as unknown as Record<string, unknown>[], // strings). The bundle is exported as JSON, so we widen to plain
addresses: addresses as unknown as Record<string, unknown>[], // `Record<string, unknown>` here. `toJsonRow` performs the narrow → wide
// widening in a single, locally-typed step instead of the prior
// `as unknown as Record<string, unknown>` double-cast.
client: toJsonRow(client),
contacts: contacts.map(toJsonRow),
addresses: addresses.map(toJsonRow),
tags: tagJoins, tags: tagJoins,
relationships: relationships as unknown as Record<string, unknown>[], relationships: relationships.map(toJsonRow),
notes: notes as unknown as Record<string, unknown>[], notes: notes.map(toJsonRow),
ownedYachts: ownedYachts as unknown as Record<string, unknown>[], ownedYachts: ownedYachts.map(toJsonRow),
companyMemberships: membershipRows as unknown as Array<{ companyMemberships: membershipRows.map((row) => ({
membership: Record<string, unknown>; membership: toJsonRow(row.membership),
company: Record<string, unknown>; company: toJsonRow(row.company),
}>, })),
interests: interestRows as unknown as Record<string, unknown>[], interests: interestRows.map(toJsonRow),
reservations: reservationRows as unknown as Record<string, unknown>[], reservations: reservationRows.map(toJsonRow),
invoices: invoiceRows as unknown as Record<string, unknown>[], invoices: invoiceRows.map(toJsonRow),
documents: documentRows as unknown as Record<string, unknown>[], documents: documentRows.map(toJsonRow),
auditTrail: auditRows as unknown as Record<string, unknown>[], auditTrail: auditRows.map(toJsonRow),
}; };
} }
/**
* Widens a Drizzle row type to `Record<string, unknown>` 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<T extends object>(row: T): Record<string, unknown> {
return row as Record<string, unknown>;
}
// ─── HTML rendering ────────────────────────────────────────────────────────── // ─── HTML rendering ──────────────────────────────────────────────────────────
function escapeHtml(s: unknown): string { function escapeHtml(s: unknown): string {
@@ -217,7 +232,7 @@ export function renderBundleHtml(bundle: GdprBundle): string {
tableSection('Client', [bundle.client]), tableSection('Client', [bundle.client]),
tableSection('Contacts', bundle.contacts), tableSection('Contacts', bundle.contacts),
tableSection('Addresses', bundle.addresses), tableSection('Addresses', bundle.addresses),
tableSection('Tags', bundle.tags as unknown as Record<string, unknown>[]), tableSection('Tags', bundle.tags.map(toJsonRow)),
tableSection('Relationships', bundle.relationships), tableSection('Relationships', bundle.relationships),
tableSection('Notes', bundle.notes), tableSection('Notes', bundle.notes),
tableSection('Owned yachts', bundle.ownedYachts), tableSection('Owned yachts', bundle.ownedYachts),

View File

@@ -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',
});
});
});