diff --git a/Dockerfile b/Dockerfile index eb1c8ea..2ba17c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,8 +42,20 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/src/generated ./src/generated -# Install prisma + dotenv for migrate deploy (prisma.config.ts imports dotenv/config) -RUN npm install --no-save prisma@7.4.2 dotenv@17.3.1 +# Install runtime deps needed for migrations + seed. The Next.js standalone +# bundle covers the app itself, but the seed script (prisma/seed-dow.ts) is +# a separate .ts file executed via tsx and needs its own module graph: +# prisma — the migrate-deploy CLI +# dotenv — prisma.config.ts imports dotenv/config +# tsx — runs the seed without an ahead-of-time compile +# @prisma/adapter-pg — the driver the seed instantiates directly +# bcryptjs — the seed hashes the admin's temp password +RUN npm install --no-save \ + prisma@7.4.2 \ + dotenv@17.3.1 \ + tsx@4 \ + @prisma/adapter-pg@7.4.2 \ + bcryptjs@3 # Create uploads directories for media storage RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index a3e840f..6d511a3 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -3,11 +3,27 @@ import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; -export async function GET() { - const checks: Record = {}; +/** + * GET /api/health + * + * Liveness probe by default — returns 200 as long as the app is responsive + * and correctly configured. Empty tables (no orgs, no templates) are + * reported but do NOT flip the status to unhealthy — a freshly-deployed + * tracker hasn't run its seed yet, and that's not a runtime failure. + * + * Pass `?strict=1` for the full readiness check including seed content — + * useful after running the seed to confirm everything landed. + */ +export async function GET(request: Request) { + const strict = new URL(request.url).searchParams.get("strict") === "1"; + + const checks: Record< + string, + { status: "ok" | "warn" | "fail"; detail?: string } + > = {}; let healthy = true; - // 1. Database connectivity + // 1. Database connectivity — liveness-critical try { await prisma.$queryRawUnsafe("SELECT 1"); checks.database = { status: "ok" }; @@ -16,7 +32,7 @@ export async function GET() { healthy = false; } - // 2. pgvector extension + // 2. pgvector extension — liveness-critical (embeddings + search need it) try { const result: any[] = await prisma.$queryRawUnsafe( "SELECT extversion FROM pg_extension WHERE extname = 'vector'" @@ -32,34 +48,44 @@ export async function GET() { healthy = false; } - // 3. Organization exists + // 3. Organization exists — content-level, only blocks health in strict mode try { const orgCount = await prisma.organization.count(); if (orgCount > 0) { checks.organization = { status: "ok", detail: `${orgCount} org(s)` }; } else { - checks.organization = { status: "fail", detail: "no organizations found" }; - healthy = false; + checks.organization = { + status: "warn", + detail: "no organizations — run `npm run db:seed`", + }; + if (strict) healthy = false; } } catch { checks.organization = { status: "fail", detail: "query failed" }; healthy = false; } - // 4. Pipeline templates exist + // 4. Pipeline templates — content-level, only blocks health in strict mode try { const templateCount = await prisma.pipelineTemplate.count(); - checks.pipeline_templates = { - status: templateCount > 0 ? "ok" : "fail", - detail: `${templateCount} template(s)`, - }; - if (templateCount === 0) healthy = false; + if (templateCount > 0) { + checks.pipeline_templates = { + status: "ok", + detail: `${templateCount} template(s)`, + }; + } else { + checks.pipeline_templates = { + status: "warn", + detail: "no pipeline templates — run `npm run db:seed`", + }; + if (strict) healthy = false; + } } catch { checks.pipeline_templates = { status: "fail", detail: "query failed" }; healthy = false; } - // 5. DEV_BYPASS_AUTH safety + // 5. DEV_BYPASS_AUTH safety — always a hard failure in production const devBypassActive = process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV === "production"; if (devBypassActive) { @@ -69,7 +95,7 @@ export async function GET() { checks.dev_bypass = { status: "ok" }; } - // 6. AUTH_SECRET set (required for next-auth v5) + // 6. AUTH_SECRET set — liveness-critical (NextAuth can't sign anything without it) if (process.env.NODE_ENV === "production" && !process.env.AUTH_SECRET) { checks.auth_secret = { status: "fail", detail: "AUTH_SECRET not set" }; healthy = false; @@ -80,6 +106,7 @@ export async function GET() { return NextResponse.json( { status: healthy ? "healthy" : "unhealthy", + mode: strict ? "strict" : "liveness", checks, timestamp: new Date().toISOString(), },