projects

CSE Martinez

status
live
domain
cse-martinez.fr
period
2025-04 → present (~12 months)
commits
97 (solo)
head
70308b6 · 2026-03-30

An internal employee portal for the works council of Hôtel Martinez (a Cannes palace hotel). Staff log in with their name — no corporate email exists for them — and browse announcements, benefits, articles, and the legally-mandated council minutes. Built and maintained solo as a Next.js 15 monolith on a single VPS. Most of the interesting engineering here is in the boundary conditions: closed user base, mixed public/private storage, and a presigned-URL bug that's stayed with me as a "things you only see in production" example.

Context

CSE Martinez is the kind of project that looks unambitious from the outside and turns out to have all the production realities. Constraints:

  • Closed user base, name-only login. The hotel's employees don't have corporate email, so auth runs on (firstName, lastName, password) with an accent-insensitive whitelist check. Anyone whose name isn't in employees_authorized can't sign up. NextAuth wraps a custom authorize() that handles two credential providers: employee (name + password) and admin (email + password).
  • Mixed asset access model. Public assets (banners, article covers, marketing images) coexist with private documents — chief among them the PV d'entreprise, the legally-mandated minutes of works council meetings. Same storage layer, two access patterns: public URLs for the public stuff, short-TTL signed URLs for the private stuff.
  • Single VPS, Traefik for SSL. No managed PaaS. Solo deploys. Docker container behind Traefik on 163.172.65.170.
  • French regulatory pages are non-negotiable. CGU, mentions légales, politique de confidentialité — added early and maintained because the client is a French company with an HR-facing portal.
  • Bulk onboarding from an Excel HR export. The hotel's HR team sends an XLSX with employee data; the importer has to be lenient about header variants (matriculeemployeeNumber, etc.) and idempotent on re-upload.

Stack

LayerChoiceWhy this one
FrontendNext.js 15.5 App Router + React 18.3Single runtime for UI + API routes — solo dev shipping both from one repo
BackendCo-located Route Handlers (36 routes)No separate service to justify
DatabaseMongoDB AtlasDocument shape fits the heterogeneous content types
AuthNextAuth 4 + bcryptjs + custom credentialsBuilt-in providers don't model dual employee/admin flows
StorageSupabase Storage (two buckets: images public, documents private)Migrated from S3 after Incident 1
HostingDocker (output: 'standalone') on VPS behind TraefikMigrated from PM2 in February 2026
CI/CDGitHub Actions → SSH deploy + rebuildPush to main triggers a rebuild on the VPS
Rich textTipTap 2.11 with image + PDF insertionArticles support embedded media
Bulk importxlsx + a custom flexible-header matcherHR exports vary; needed variant matching and upsert

The presigned URL trap

This is the bug that taught me something I now check for in every Next.js + private-storage project. Discovered February 2026.

Symptom

All banners and article covers across the site were rendering as broken images. Production logs were full of 403 Forbidden responses from S3.

Root cause

Images in the S3 bucket were served via presigned URLs — short-lived signatures attached to the URL, valid for some TTL. The frontend received the presigned URL and dropped it into a Next.js <Image> component.

That's where it got interesting. Next.js Image Optimization doesn't fetch the URL from the browser — it fetches it server-side from the Next.js server to resize and cache. By the time the optimizer re-fetched a previously-cached URL (for a different size, a different request, a different user), the signature had expired. Result: 403 Forbidden, broken image.

The bug was the perfect intersection of two things working as designed. Presigned URLs are supposed to expire. Next.js Image Optimization is supposed to re-fetch sources to do its work. They just don't compose well unless you specifically design for it.

There was an earlier band-aid attempt — c38e5a1 ("disable cache to avoid expired S3 URLs") — that patched the symptom at the page level without identifying the root cause. The proper fix came a couple of weeks later.

Fix

7b0b414. Switched S3 access from presigned to direct public URLs via a new buildS3Url() helper. The same commit also ripped out PM2 deployment and replaced it with Docker + output: 'standalone' — those two changes were entangled because the deploy migration revealed the URL bug under load.

Three days later, 77b3b93: the entire storage layer got rewritten. S3 was removed; Supabase Storage took over with explicit public-vs-private bucket separation. Signed URLs now exist only where they belong — for the actual private documents (PV d'entreprise), with a 60-second default TTL and 5-minute extensions for article PDF embeds.

The lesson lands in one sentence: if your image source URL expires and your image optimizer re-fetches, you have a bug. The first instinct of every developer I've seen explain this is "but the URL is valid when the page loads" — yes, and that's why it's a subtle one.

Migrations as project arcs

This project's most interesting commits aren't features — they're migrations. Two big ones, both clustered in February 2026:

  • PM2 → Docker. PM2 was the original deploy target. Worked fine but didn't isolate well, didn't standardize restarts, and made Let's Encrypt fiddly. Migrated in the same commit as the S3 fix above. Restart and isolation got better; rollouts got slower (full rebuild on every deploy) — a worthwhile trade.
  • S3 → Supabase Storage. Three days after the PM2 migration. Triggered by the presigned URL bug but justified by cost and simplicity. Same project as the auth + DB layer, fewer credentials, public/private buckets out of the box.

Two big migrations in a single week, on a live system, solo, with no test suite to catch regressions. The site held up. That's not a brag — it's the operational truth that explains how solo projects survive: ruthless scoping of what you change at once, and a willingness to ship a fix that fixes more than the symptom.

Earlier in the project's life, there's also a preemptive patch for CVE GHSA-9qr9-h5gf-34mp (Next.js 15 RCE, CVSS 10.0) shipped within hours of the upstream advisory. No exploit in the wild, but on a French-employee portal with PV documents behind auth, you don't wait.

Worth reading

Retrospective

  • Telemetry never landed. Sentry is mentioned in .env.example as optional but was never wired up. The presigned URL bug got caught by visual inspection — a metric like "5xx rate on /_next/image" would have surfaced it in hours instead of days.
  • The Excel importer is the codebase's biggest LOC liability. 772 lines of XLSX parsing for header variants is a lot of code that only runs occasionally. A more constrained import template (a single canonical XLSX format that HR fills in) would shrink the script by an order of magnitude. The constraint here is human, not technical — HR sends what HR has.
  • The S3 → Supabase migration should have come first. Choosing S3 in early 2025 wasn't wrong, but it was overkill for a single-tenant French employee portal. The same operational footprint with Supabase from day one would have saved the migration and the URL bug along with it. Worth remembering when picking storage for the next "small" project.
  • output: 'standalone' should have been the default from the start. It was added a year into the project, alongside the Docker migration. The advantages (small image, isolated runtime, predictable cold start) are the kind of thing that compound — adding it earlier would have made every subsequent deploy cheaper.
  • The two dormant stretches (Jul–Sep 2025, Nov 2025) are visible in the commit graph. Solo client work has cadences, and being honest about them in the project's pacing helps explain how a "12 months in production" project really only had six or seven months of actual development. Worth saying out loud.

build

Workflow: spec → plan-first session → parallel subagents → automated review → manual call on the gray areas. Production decisions, architecture, debugging, and incident response are mine. Code generation is the agent's. The portfolio itself documents this workflow in /projects/portfolio.