Skip to content

Migrate to Next.js + TypeScript with Prisma on Neon Postgres, migration rules and tests - #34

Merged
cliffkoome merged 6 commits into
mainfrom
feat/nextjs-prisma-neon-migration
Oct 19, 2026
Merged

cliffkoome merged 6 commits into
mainfrom
feat/nextjs-prisma-neon-migration

Conversation

@cliffkoome

@cliffkoome cliffkoome commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Migrates MatConnect from Express + Sequelize/MariaDB + static HTML to Next.js 16 (App Router) + TypeScript, Prisma 7 on Neon Postgres, with enforced migration rules, a layered server architecture and a test suite.

What changed

Architecture

  • src/app/api/**/route.ts — thin HTTP layer; same endpoints as before (api-tests.http updated)
  • src/server/http/route() — one wrapper for rate limits, auth/role checks, params and error mapping (HttpError / Zod / Prisma → { message, errors? })
  • src/server/modules/* — business logic per feature; src/server/domain/* — pure ETA/geo/date logic; src/server/integrations/* — Firebase, Google Maps, Africa's Talking (REST), SMTP
  • Client code can't import server code (ESLint rule)

Database (Prisma + Neon)

  • Schema ported from the Sequelize models: snake_case tables, timestamptz, explicit join tables, explicit onDelete, CHECK on feedback rating
  • Pooled DATABASE_URL at runtime (Neon serverless driver), direct DIRECT_URL for migrations
  • New tables: revoked_tokens (logout/refresh rotation — works on serverless, unlike the old in-memory blocklist) and job_locks (lease so only one ETA cycle runs at a time)

Migration rules — see docs/MIGRATIONS.md

  • npm run db:migrate:check: merged migrations immutable, strict ordering, naming, destructive SQL (DROP COLUMN, SET NOT NULL, renames, …) needs an explicit -- migration-safety: destructive-approved <reason>
  • CI: drift check (migrations vs schema), apply full history to a fresh Postgres, verify DB == schema, re-deploy is a no-op
  • neon-preview.yml: per-PR Neon branch copied from production with the PR's migrations applied
  • deploy-migrations.yml: the only path to production (migrate deploy on merge to main, in a production environment)
  • CODEOWNERS for prisma/

ETA background cycle — runs every 30 s in-process under next start, via /api/cron/eta-cycle (Vercel Pro cron, ?runs=2), or as npm run eta:worker.

Frontend — every page ported to React with the original markup/CSS (scoped per page), typed API client with automatic token refresh, old *.html URLs redirect. admin-settings.html and stage-details.html were unlinked mock-ups and were dropped.

Bugs fixed along the way

  • The 2FA temp token was signed like an access token and accepted by the auth middleware — the second factor could be skipped. Tokens now carry a typ claim.
  • /refresh-token always crashed (isBlocklisted was never imported).
  • Blocking a user or resetting a password didn't affect live access tokens (the check compared the token with itself). The guard now reads the user row.
  • Google sign-in: the server put the token in the URL fragment, the page read the query string.
  • Several pages expected arrays where the API returned { items, total }.
  • sameSite=none refresh cookie → now Lax, httpOnly, scoped to /api/auth.

Testing

  • npm test: 207 unit + route-handler tests (domain logic, JWTs incl. the 2FA regression, TOTP incl. old speakeasy secrets, guard, services with mocked Prisma, ETA cycle, migration rules)
  • npm run test:integration: 11 tests against real Postgres 17 — constraints, unique-violation mapping, concurrent trip counting, job lease, revocation, timezone handling. These caught a real bug: timestamp columns + a non-UTC server shifted every write by the server offset, so the schema now uses timestamptz and sessions run in UTC.
  • Ran the built app against Postgres and exercised every endpoint with curl, then clicked through every page in headless Chromium (login per role, feedback, SMS subscribe, profile + 2FA setup, stage/vehicle management, charts).
  • Not run here: anything needing real Neon, Firebase, Google or Africa's Talking credentials, and the GitHub workflows themselves.

Before merging / deploying

  1. Neon: copy the pooled URL to DATABASE_URL and the direct URL to DIRECT_URL (see .env.example).
  2. GitHub: secrets DATABASE_URL_DIRECT_PRODUCTION, NEON_API_KEY; variable NEON_PROJECT_ID; environment production.
  3. Hosting env: JWT_SECRET, JWT_REFRESH_SECRET, APP_URL, CRON_SECRET + optional integrations. Firebase now uses FIREBASE_CREDENTIALS_BASE64 (no JSON file on disk).
  4. Google OAuth redirect URI changes to ${APP_URL}/api/auth/google/callback.
  5. Existing MariaDB data: table/column mapping and a pgloader note are in docs/MIGRATIONS.md. Password hashes and 2FA secrets carry over unchanged.
  6. On Vercel Hobby, crons are daily-only — use an external scheduler or npm run eta:worker for the 30 s cycle (README has the options).

Generated by Claude Code

Removes the Express server, Sequelize models and static HTML/JS pages and sets up
Next.js (App Router), TypeScript, ESLint, legacy *.html redirects and security headers.
- Models ported from Sequelize with snake_case tables, timestamptz, explicit FKs
- Explicit join tables, soft-deleted vehicles, revoked_tokens and job_locks tables
- CHECK constraint on feedback rating (added by hand to the init migration)
- prisma.config.ts: pooled DATABASE_URL at runtime, DIRECT_URL for the CLI
- Idempotent dev seed
route() wrapper (rate limits, auth, params, error mapping) -> feature services ->
Prisma; pure domain logic for ETA/geo/periods; integrations for Firebase, Google Maps,
Africa's Talking and SMTP; ETA cycle with a Postgres job lease, runnable from Vercel
Cron, an in-process scheduler or a standalone worker.

Fixes from the old server: typed JWTs (2FA temp token was accepted as an access token),
working refresh rotation (isBlocklisted was never imported), blocks/password resets
take effect immediately, Google sign-in redirect read the wrong part of the URL.
Same markup and styles as the old pages (CSS scoped per page), a typed API client with
automatic token refresh, role guards, and fixes for responses the old pages misread
(paginated lists, feedback feed). The vehicle stats date picker now works.
- scripts/check-migrations.mjs: immutability, ordering, naming, destructive-SQL guard
- CI: lint/typecheck/test/build + migrations job (drift, apply, verify, integration)
- Neon preview branch per PR; production deploys only from main
- docs/MIGRATIONS.md, README, .env.example, api-tests.http
@cliffkoome
cliffkoome force-pushed the feat/nextjs-prisma-neon-migration branch from e7ea8d7 to e0a67c6 Compare October 19, 2026 21:13
@cliffkoome
cliffkoome merged commit 79ce79c into main Oct 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant