diff --git a/.env.example b/.env.example index 4adde89..9f39eef 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see # docs/phase-7-summary.md; the production host only supports MySQL). -DATABASE_URL="mysql://user:password@localhost:3306/nextchair" +DATABASE_HOST="localhost" +DATABASE_PORT="3306" +DATABASE_NAME="nextchair" +DATABASE_USER="user" +DATABASE_PASSWORD="password" # Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this # for a hosting provider with a small max-connections limit. DATABASE_CONNECTION_LIMIT="5" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5c99bf9..c303721 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,7 +28,11 @@ concurrency: env: # A throwaway, non-secret DB the mysql service creates for this run only # — never a real deployment's database. - DATABASE_URL: mysql://root:root@127.0.0.1:3306/nextchair + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: "3306" + DATABASE_NAME: nextchair + DATABASE_USER: root + DATABASE_PASSWORD: root # Any fixed value works (see src/lib/booking-draft/sign.ts) — only needs # to be consistent for the lifetime of this one CI run. AUTH_SECRET: ci-run-secret-do-not-use-in-production diff --git a/.gitignore b/.gitignore index c0b9118..90390d3 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ yarn-error.log* .env.local.example .env.example .env.example + +.agents/ +.claude/ +.windsurf/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8464f68..e01f2fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,18 +26,23 @@ FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# DATABASE_URL isn't needed to generate the client (no DB connection at -# generate time) — only real values needed at runtime, never baked in here. +# These database env vars aren't needed to generate the client (no DB +# connection at generate time) — only real values needed at runtime, never +# baked in here. RUN npx prisma generate # `next build`'s page-data collection step imports every route module # (including /api/health) to inspect it — that pulls in -# src/lib/db/prisma.ts, which requires DATABASE_URL just to construct its -# connection-pool config object, even though nothing actually connects at -# build time. This placeholder only exists to satisfy that check; the -# real value is supplied at container runtime via env_file (see -# docker-compose.local.yaml) and this one is never used to connect to -# anything or baked into a layer that matters. -ENV DATABASE_URL="mysql://build:build@localhost:3306/build" +# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD +# just to construct its connection-pool config object, even though nothing +# actually connects at build time. These placeholders only exist to +# satisfy that check; the real values are supplied at container runtime +# via env_file (see docker-compose.local.yaml) and these are never used to +# connect to anything or baked into a layer that matters. +ENV DATABASE_HOST="localhost" +ENV DATABASE_PORT="3306" +ENV DATABASE_NAME="build" +ENV DATABASE_USER="build" +ENV DATABASE_PASSWORD="build" RUN npm run build FROM node:22-alpine AS runner diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index aceff7e..83a44c6 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -2,16 +2,17 @@ import { execFileSync } from "node:child_process"; /** * Applies migrations and loads the dev/demo seed data against whatever - * DATABASE_URL points at before the suite runs. Deliberately does not - * create or tear down the database itself — same division of - * responsibility as `npm run test:integration`, where the caller is - * responsible for pointing DATABASE_URL at a disposable Postgres. + * DATABASE_HOST/PORT/NAME/USER/PASSWORD point at before the suite runs. + * Deliberately does not create or tear down the database itself — same + * division of responsibility as `npm run test:integration`, where the + * caller is responsible for pointing these at a disposable database. */ export default function globalSetup() { - if (!process.env.DATABASE_URL) { + if (!process.env.DATABASE_HOST || !process.env.DATABASE_NAME || !process.env.DATABASE_USER) { throw new Error( - "DATABASE_URL is not set. Point it at a disposable Postgres before running the e2e suite, e.g.\n" + - ' DATABASE_URL="postgresql://user:pass@localhost:5432/nextchair_e2e" npm run test:e2e', + "Database connection is not configured. Point DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and " + + "DATABASE_PASSWORD at a disposable MySQL database before running the e2e suite, e.g.\n" + + ' DATABASE_HOST=localhost DATABASE_NAME=nextchair_e2e DATABASE_USER=user DATABASE_PASSWORD=pass npm run test:e2e', ); } diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts index 4b86fc5..d13d69c 100644 --- a/e2e/owner-journeys.spec.ts +++ b/e2e/owner-journeys.spec.ts @@ -20,7 +20,7 @@ import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers"; test.describe.serial("owner administration journeys", () => { test.afterAll(async () => { - const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); + const pool = createPool(buildMysqlPoolConfig()); // Journey 7 creates a category/service with no deterministic id to // upsert against on a re-run — clean up by name instead. await pool.query("DELETE FROM `Service` WHERE name LIKE 'E2E Test Service%'"); @@ -108,7 +108,7 @@ test.describe.serial("owner administration journeys", () => { // them from an earlier seed run their date is whatever it was back // then, not necessarily still "N days from today" — read the real date // straight from the database instead of predicting it from wall-clock time. - const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); + const pool = createPool(buildMysqlPoolConfig()); const rows = await pool.query<{ id: string; start_at: Date; first_name: string; last_name: string }[]>( "SELECT a.id, a.startAt as start_at, c.firstName as first_name, c.lastName as last_name" + " FROM Appointment a JOIN CustomerProfile c ON c.id = a.customerId" + diff --git a/e2e/staff-journeys.spec.ts b/e2e/staff-journeys.spec.ts index 7bdc07a..d59e3a9 100644 --- a/e2e/staff-journeys.spec.ts +++ b/e2e/staff-journeys.spec.ts @@ -31,7 +31,7 @@ test.describe.serial("barber operations journeys", () => { // safety net removes them directly regardless of how the tests above // exited, on top of (not instead of) each test's own UI-driven cleanup. test.afterAll(async () => { - const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); + const pool = createPool(buildMysqlPoolConfig()); await pool.query("DELETE FROM `TimeOff` WHERE reason = 'E2E conflict test'"); await pool.query("DELETE FROM `Break` WHERE label = 'Afternoon tea'"); await pool.end(); diff --git a/playwright.config.ts b/playwright.config.ts index 0f99451..4ff1637 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,12 +2,12 @@ import { defineConfig, devices } from "@playwright/test"; /** * End-to-end customer journeys — real browser, real dev server, real - * Postgres. Needs DATABASE_URL pointed at a throwaway database before - * running (same "migrate, seed, test, tear down" pattern as - * `npm run test:integration` — see docs/phase-3-summary.md and - * docs/phase-4-summary.md). `globalSetup` applies migrations and seed - * data automatically so a single `npm run test:e2e` is enough once - * DATABASE_URL is set. + * MySQL. Needs DATABASE_HOST/DATABASE_NAME/DATABASE_USER/DATABASE_PASSWORD + * pointed at a throwaway database before running (same "migrate, seed, + * test, tear down" pattern as `npm run test:integration` — see + * docs/phase-3-summary.md and docs/phase-4-summary.md). `globalSetup` + * applies migrations and seed data automatically so a single + * `npm run test:e2e` is enough once those are set. */ const PORT = 3100; const baseURL = `http://localhost:${PORT}`; @@ -34,7 +34,6 @@ export default defineConfig({ timeout: 60_000, env: { ...process.env, - DATABASE_URL: process.env.DATABASE_URL ?? "", // Any fixed value is fine — see src/lib/booking-draft/sign.ts. Only // needs to be consistent for the lifetime of this one server process. AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production", diff --git a/prisma.config.ts b/prisma.config.ts index daed51a..a29cb0b 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,6 +1,12 @@ import "dotenv/config"; import { defineConfig } from "prisma/config"; +import { buildMysqlConnectionUrl } from "./src/lib/db/mysql-pool-config"; +// The Prisma CLI (migrate/introspect) needs a connection string, not +// discrete fields — this app's own runtime code never uses this URL (see +// src/lib/db/mysql-pool-config.ts's buildMysqlPoolConfig, which reads +// DATABASE_HOST/PORT/NAME/USER/PASSWORD directly), so it's built here only +// to satisfy the CLI. export default defineConfig({ schema: "prisma/schema.prisma", migrations: { @@ -8,6 +14,6 @@ export default defineConfig({ seed: "tsx prisma/seed.ts", }, datasource: { - url: process.env["DATABASE_URL"], + url: buildMysqlConnectionUrl(), }, }); diff --git a/prisma/seed.ts b/prisma/seed.ts index 18f94d4..34ebe1f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,6 +1,6 @@ /** * Dev/demo fixtures. Run with `npm run prisma:seed` (requires a reachable - * DATABASE_URL — see .env.example). Intentionally small: enough to exercise + * DATABASE_HOST/NAME/USER/PASSWORD — see .env.example). Intentionally small: enough to exercise * every relation in the schema once, not a full demo dataset. */ import "dotenv/config"; @@ -28,7 +28,7 @@ function minutesFromNow(minutes: number): Date { return new Date(Date.now() + minutes * 60_000); } -const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); +const adapter = new PrismaMariaDb(buildMysqlPoolConfig()); const prisma = new PrismaClient({ adapter }); const WEEKDAYS: Weekday[] = [ diff --git a/public/sw.js b/public/sw.js index 4991434..05f5371 100644 --- a/public/sw.js +++ b/public/sw.js @@ -19,11 +19,19 @@ // non-GET request (Server Actions, API mutations) is never intercepted at // all — this worker must never sit between the app and a write. -const CACHE_VERSION = "v1"; +// v2: /manifest.webmanifest was previously cache-first here alongside the +// truly static icon fallbacks — but it's dynamic, per-business content +// (name/icons/colors, editable from "App Icon & Home Screen" settings), +// not a static build asset. Cache-first meant a phone that had ever +// loaded it once would keep serving that stale response indefinitely, +// regardless of any later branding change or server-side fix — bumping +// CACHE_VERSION forces every existing install to drop its old cache +// (including any previously-cached manifest response) on next activate. +const CACHE_VERSION = "v2"; const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`; const OFFLINE_URL = "/offline"; -const APP_SHELL = [OFFLINE_URL, "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; +const APP_SHELL = [OFFLINE_URL, "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { event.waitUntil( diff --git a/scripts/ensure-super-admin.ts b/scripts/ensure-super-admin.ts index 704af03..ee00f23 100644 --- a/scripts/ensure-super-admin.ts +++ b/scripts/ensure-super-admin.ts @@ -45,7 +45,7 @@ async function main() { const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? ""; const name = [firstName, lastName].filter(Boolean).join(" ") || null; - const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); + const adapter = new PrismaMariaDb(buildMysqlPoolConfig()); const prisma = new PrismaClient({ adapter }); try { diff --git a/src/lib/business/branding.ts b/src/lib/business/branding.ts index 3d866b1..f0b8a33 100644 --- a/src/lib/business/branding.ts +++ b/src/lib/business/branding.ts @@ -102,7 +102,7 @@ export const getBranding = cache(async (): Promise => { // Reached from the root layout's generateMetadata, which Next may // invoke while statically prerendering a route at *build* time (e.g. // /_not-found) — no real database is reachable then (see the - // Dockerfile's placeholder build-time DATABASE_URL). A transient/ + // Dockerfile's placeholder build-time database env vars). A transient/ // unreachable database is treated exactly like "not installed yet": // neutral fallback wording, never a crashed build or a 500 at runtime. let business; diff --git a/src/lib/db/mysql-pool-config.ts b/src/lib/db/mysql-pool-config.ts index 4db9f00..6143298 100644 --- a/src/lib/db/mysql-pool-config.ts +++ b/src/lib/db/mysql-pool-config.ts @@ -1,31 +1,64 @@ /** - * Builds the `mariadb` pool config from `DATABASE_URL`, shared by every - * Prisma Client instance in the app (the main app client, the seed - * script, and the integration-test fixtures) so the UTC-timezone and - * connection-limit handling can't drift between them. See + * Builds the `mariadb` pool config from the discrete `DATABASE_HOST` / + * `DATABASE_PORT` / `DATABASE_NAME` / `DATABASE_USER` / `DATABASE_PASSWORD` + * env vars, shared by every Prisma Client instance in the app (the main + * app client, the seed script, and the integration-test fixtures) so the + * UTC-timezone and connection-limit handling can't drift between them. See * src/lib/db/prisma.ts for why `timezone`/`sessionVariables` matter — this * file exists only because that reasoning needs to apply to all three * call sites, not because the logic itself is complex. */ -export function buildMysqlPoolConfig(databaseUrl: string | undefined) { - if (!databaseUrl) throw new Error("DATABASE_URL is not set."); +export function buildMysqlPoolConfig() { + const host = process.env.DATABASE_HOST; + const database = process.env.DATABASE_NAME; + const user = process.env.DATABASE_USER; + const password = process.env.DATABASE_PASSWORD; - // Accept both `mysql://` and `mariadb://` — the `mariadb` driver's own - // string parser only recognizes the latter, but the `URL` global parses - // either scheme fine since we build the pool config object ourselves. - const url = new URL(databaseUrl); + if (!host || !database || !user || password === undefined) { + throw new Error( + "Database connection is not configured. Set DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD (DATABASE_PORT is optional, defaults to 3306).", + ); + } return { - host: url.hostname, - port: url.port ? Number(url.port) : 3306, - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), + host, + port: process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306, + user, + password, + database, connectionLimit: Number(process.env.DATABASE_CONNECTION_LIMIT ?? 5), connectTimeout: Number(process.env.DATABASE_CONNECT_TIMEOUT_MS ?? 10_000), // Load-bearing, not cosmetic — see src/lib/db/prisma.ts's file header. timezone: "Etc/UTC", sessionVariables: { time_zone: "+00:00" }, ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: true } : undefined, + // MySQL 8's default auth plugin (caching_sha2_password) needs to send + // the client its RSA public key to encrypt the password, and the + // `mariadb` driver refuses to fetch it over a plain connection unless + // told to — surfacing as "Public Key Retrieval is not allowed" on + // every connection attempt (not intermittent, not env-specific: it + // fires against any caching_sha2_password server whenever DATABASE_SSL + // isn't "true"). Opting in doesn't weaken authentication itself, only + // lets the client ask for that public key; a connection that's already + // on `ssl` above doesn't need this exchange in the first place. + allowPublicKeyRetrieval: true, }; } + +/** + * Builds a `mysql://` connection string from the same discrete env vars, + * for the one place that genuinely needs a URL rather than a pool config + * object: prisma.config.ts, whose `datasource.url` is a plain string + * because the Prisma CLI (migrate/introspect) doesn't accept discrete + * connection fields. + */ +export function buildMysqlConnectionUrl(): string | undefined { + const host = process.env.DATABASE_HOST; + const database = process.env.DATABASE_NAME; + const user = process.env.DATABASE_USER; + const password = process.env.DATABASE_PASSWORD; + if (!host || !database || !user || password === undefined) return undefined; + + const port = process.env.DATABASE_PORT || "3306"; + return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`; +} diff --git a/src/lib/db/prisma.ts b/src/lib/db/prisma.ts index ea69eeb..b9c8287 100644 --- a/src/lib/db/prisma.ts +++ b/src/lib/db/prisma.ts @@ -28,7 +28,7 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; -const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); +const adapter = new PrismaMariaDb(buildMysqlPoolConfig()); export const prisma = globalForPrisma.prisma ?? diff --git a/src/lib/media/__tests__/media-storage.test.ts b/src/lib/media/__tests__/media-storage.test.ts index 3d2c4c0..338d35a 100644 --- a/src/lib/media/__tests__/media-storage.test.ts +++ b/src/lib/media/__tests__/media-storage.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; * Real filesystem I/O against a throwaway temp directory (via * MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting * real file paths right, so a mocked `fs` would test nothing useful. No - * DATABASE_URL/Prisma dependency here (media-storage.ts is DB-free), so + * database/Prisma dependency here (media-storage.ts is DB-free), so * this stays safe to run in the plain unit suite. The module is * dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads * that env var once at module-load time. diff --git a/src/services/reviews/__tests__/reviews.service.test.ts b/src/services/reviews/__tests__/reviews.service.test.ts index 8da71b6..759cc09 100644 --- a/src/services/reviews/__tests__/reviews.service.test.ts +++ b/src/services/reviews/__tests__/reviews.service.test.ts @@ -5,7 +5,7 @@ import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service" describe("reviews.service", () => { // The module has no eager default provider (see reviews.service.ts's // header — it lazily resolves a real, DB-backed provider on first use - // otherwise, which would need a live DATABASE_URL). Every test here + // otherwise, which would need live database env vars). Every test here // explicitly opts into the demo provider so none of them ever touch it. beforeEach(() => { setReviewsProvider(createDemoReviewsProvider()); diff --git a/src/services/reviews/reviews.service.ts b/src/services/reviews/reviews.service.ts index c738426..cc5d8c8 100644 --- a/src/services/reviews/reviews.service.ts +++ b/src/services/reviews/reviews.service.ts @@ -102,8 +102,8 @@ export class DbReviewsProvider implements ReviewsProvider { * `null` = no explicit override; resolved lazily to a real DbReviewsProvider * on first use. Deliberately NOT constructed eagerly at module load — this * file must stay importable from a pure unit-test context with no - * DATABASE_URL (see reviews.service.test.ts), and src/lib/db/prisma.ts - * throws at import time if that's unset. + * database env vars set (see reviews.service.test.ts), and src/lib/db/prisma.ts + * throws at import time if they're unset. */ let provider: ReviewsProvider | null = null; let cachedDefaultProvider: ReviewsProvider | null = null; diff --git a/src/services/scheduling/__tests__/fixtures.ts b/src/services/scheduling/__tests__/fixtures.ts index 574d1f1..1134d38 100644 --- a/src/services/scheduling/__tests__/fixtures.ts +++ b/src/services/scheduling/__tests__/fixtures.ts @@ -11,7 +11,7 @@ import { Weekday } from "@/generated/prisma/enums"; * (migrated from PostgreSQL to MySQL in Phase 7). */ -const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); +const adapter = new PrismaMariaDb(buildMysqlPoolConfig()); export const prisma = new PrismaClient({ adapter }); let counter = 0;