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

@@ -145,25 +145,40 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
clientId,
schemaVersion: 1,
},
client: client as unknown as Record<string, unknown>,
contacts: contacts as unknown as Record<string, unknown>[],
addresses: addresses as unknown as Record<string, unknown>[],
// Drizzle row types contain non-`unknown` value types (Date, branded
// strings). The bundle is exported as JSON, so we widen to plain
// `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,
relationships: relationships as unknown as Record<string, unknown>[],
notes: notes as unknown as Record<string, unknown>[],
ownedYachts: ownedYachts as unknown as Record<string, unknown>[],
companyMemberships: membershipRows as unknown as Array<{
membership: Record<string, unknown>;
company: Record<string, unknown>;
}>,
interests: interestRows as unknown as Record<string, unknown>[],
reservations: reservationRows as unknown as Record<string, unknown>[],
invoices: invoiceRows as unknown as Record<string, unknown>[],
documents: documentRows as unknown as Record<string, unknown>[],
auditTrail: auditRows as unknown as Record<string, unknown>[],
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<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 ──────────────────────────────────────────────────────────
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<string, unknown>[]),
tableSection('Tags', bundle.tags.map(toJsonRow)),
tableSection('Relationships', bundle.relationships),
tableSection('Notes', bundle.notes),
tableSection('Owned yachts', bundle.ownedYachts),