Files
lisilou-portfolio/CLAUDE.md
T
jhodgkin 016256cd2f
Deploy to Dev / Deploy & Smoke Test (push) Successful in 20s
Deploy to Prod / Deploy & Smoke Test (prod) (push) Failing after 30s
Add registry-based prod promotion, separate from auto dev deploy
Dev keeps auto-deploying on every push to main, same as before, but now
also publishes each build to git.jerodrigged.com's container registry
tagged by short commit SHA (best-effort - never blocks the dev deploy
if REGISTRY_TOKEN isn't set yet).

Prod deploys only on an intentional `git push origin main:prod`, and
only ever pulls a pre-built SHA-tagged image - it never rebuilds from
source. This guarantees prod runs the exact artifact dev already
validated, and makes promoting an untested commit fail loudly (pull of
a nonexistent tag) instead of silently rebuilding something new.

Needs new secrets before the prod path works: PROD_SSH_KEY, PROD_HOST,
PROD_USER, REGISTRY_USER, REGISTRY_TOKEN. PROD_SSH_KEY/PROD_HOST/
REGISTRY_USER are already set; PROD_USER and REGISTRY_TOKEN still need
Jerod's input.
2026-07-20 04:47:49 +00:00

9.5 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code when working with code in this repository.

Project Overview

LisiLou Photography Portfolio — a photography booking and portfolio site for Elysse Hodgkin. Built as a zero-framework SPA with a Node.js/Express booking API, deployed on a homelab Proxmox cluster.

Live dev URL: http://192.168.1.192:8080 (CT114, lisilou-dev LXC) Production URL: https://lisilou.jerodrigged.com (CT111 via Cloudflare tunnel → NPM CT102) Gitea repo: https://git.jerodrigged.com/jhodgkin/lisilou-portfolio (deploys dev, CT114) GitHub mirror: https://github.com/jhodgkin/lisilou-portfolio (deploys prod, CT111 — push here too or prod drifts!)

Technology Stack

Layer Tech
Frontend Vanilla HTML5/CSS3/JS (no framework), single file
Portfolio server Nginx Alpine
Booking API Node.js 20 / Express / better-sqlite3
Deployment Docker Compose (two services: portfolio + api)
CI/CD Gitea Actions → CT114 (dev); GitHub Actions → CT111 (prod) on push to main
Image hosting Immich (immich.jerodrigged.com)

Development Commands

# Start full stack locally (portfolio on :8080, API on :3001 internally)
docker compose up -d

# Rebuild after changes to src/index.html or api/
docker compose build && docker compose up -d

# API logs only
docker logs lisilou-api

# Run smoke tests against dev LXC
bash scripts/smoke-test.sh http://192.168.1.192:8080

Volume-mounted (no rebuild needed):

  • config/ — site content and booking config
  • public/images/ — portfolio and location photos
  • api/data/ — SQLite database
  • api/signed-contracts/ — completed contract PDFs

Architecture

Two-Service Docker Compose

Browser → Nginx (:8080)
              ├── /          → src/index.html (SPA)
              ├── /config/   → config/ volume (5-min cache)
              ├── /images/   → public/images/ volume (1-year cache)
              ├── /api/      → proxy → api:3001 (Node.js)
              └── /health    → 200 OK

Single-File SPA (src/index.html, ~1800 lines)

  • Lines 1900: Inline CSS with CSS custom properties for theming
  • Lines 900+: HTML structure and vanilla JavaScript
  • Booking modal is a 7-step wizard rendered entirely in this file

Configuration-Driven Content (config/site.json)

All content is loaded at runtime — no rebuild needed:

site.json
├── site         — title, tagline, logo, favicon, heroImage (optional hero photo)
├── photographer — name, bio, profile image
├── contact      — email, phone
├── social       — instagram, facebook, etc.
├── immich       — baseUrl, publicAlbumPrefix
├── portfolio    — categories with immichAlbumId
├── clientAccess — enable/disable client gallery login
├── locations[]  — location picker cards (see below)
├── booking      — sessionTypes[], pricing {mini, full}, venmoUsername
└── theme        — colors, fonts

Booking API (api/)

Express app at port 3001 with SQLite (api/data/bookings.db).

api/
├── server.js       — Express app (routes, CORS, JSON middleware)
├── db.js           — SQLite init; creates bookings table on first run
├── package.json
├── package-lock.json  ← committed; required for npm ci in Docker
├── Dockerfile
├── .env            ← gitignored; copy from .env.example
└── .env.example    ← committed template

Bookings table columns: id, created_at, client_name, client_email, client_phone, client_sub (OIDC), session_date, session_type, session_length, location, contract_signed_at, contract_pdf_path, payment_status, payment_notified_at, status, notes

API env vars (set in api/.env on the server, never committed):

PORT=3001
CORS_ORIGIN=http://localhost:8080
N8N_WEBHOOK_URL=
GOOGLE_CALENDAR_ID=
GOOGLE_SERVICE_ACCOUNT_JSON=
SESSION_SECRET=
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=

Location Photos

Drop images into public/images/locations/<location-id>/ on the server. No rebuild needed — nginx serves the volume directly with 1-year caching.

public/images/locations/
├── river-bottoms/   hero.jpg, 1.jpg, 2.jpg
├── city-park/       hero.jpg, 1.jpg, 2.jpg
├── downtown/        hero.jpg, 1.jpg, 2.jpg
└── studio/          hero.jpg, 1.jpg, 2.jpg

Missing images fall back to a styled placeholder — safe to deploy before photos are ready.

Key Files

File Purpose
src/index.html Complete SPA — CSS + HTML + booking wizard JS
config/site.json All runtime content: portfolio, booking, locations, theme
config/profiles.json Multi-tenant domain → config file routing
nginx.conf Proxy rules, caching, security headers, SPA fallback
Dockerfile Portfolio image (Node Alpine → Nginx Alpine, multi-stage)
api/Dockerfile API image (Node 20 Alpine + build tools for native deps)
docker-compose.yml Both services, volumes, healthchecks
.gitea/workflows/deploy.yml Push-to-main → SSH deploy → smoke tests
scripts/smoke-test.sh 6 curl assertions; exits non-zero on failure

CI/CD — Dev auto-deploys, prod is a deliberate promotion

Both pipelines run on Gitea Actions (git.jerodrigged.com). GitHub is not part of the deploy story — main on the GitHub mirror is unused.

Dev (every push to main).gitea/workflows/deploy.yml:

  1. Gitea runner (CT117) SSHes into CT114 (dev LXC at 192.168.1.192)
  2. git fetch origin && git reset --hard origin/main — robust, never fails on drift
  3. docker compose build (tags images lisilou-portfolio-web:local / lisilou-portfolio-api:local)
  4. If REGISTRY_TOKEN is set: tag+push both images to git.jerodrigged.com/jhodgkin/lisilou-portfolio-{web,api}:<short-sha> — best-effort, a publish failure never blocks the dev deploy itself
  5. docker compose up -d (runs the just-built local image)
  6. Health check loop (24 × 5s attempts), then bash scripts/smoke-test.sh

Prod (only on git push origin main:prod, or <sha>:prod to pin an older commit).gitea/workflows/deploy-prod.yml:

  1. Gitea runner SSHes into CT111 (prod LXC, 192.168.1.246)
  2. git fetch origin && git reset --hard origin/prod, then computes the same short SHA dev used to tag its published image
  3. docker compose -f docker-compose.yml -f docker-compose.deploy.yml pull — pulls that exact SHA-tagged image from the registry. No docker compose build ever runs here — this is what guarantees prod runs the same artifact dev already validated, not a fresh rebuild that could drift. Pulling a SHA that dev never published fails loudly instead of silently rebuilding.
  4. docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d
  5. Same health check + smoke test pattern, against CT111

docker-compose.deploy.yml is the override that swaps each service's image: for the registry-tagged one; it's only ever used for the prod pull, never for local dev (docker compose up -d alone still builds from source as before).

Gitea secrets required: DEV_SSH_KEY, DEV_HOST, DEV_USER (dev); PROD_SSH_KEY, PROD_HOST, PROD_USER (prod); REGISTRY_USER, REGISTRY_TOKEN (both pipelines — a Gitea access token scoped write:package,read:package).

Booking Wizard — Implementation Status

Step Feature Issue Status
1 Date picker #3 Done — Google Calendar busy dates (graceful when unconfigured)
2 Session type #4 Done — config-driven from sessionTypes[]
3 Session length #4 Done — mini/full with pricing from config
4 Location picker #5 Done — photo cards with detail expand panel
5 Contract e-signature #6 Done — PDF.js viewer + canvas pad; needs api/contracts/model-release.pdf on volume
6 Venmo payment #7 Done — deep link + server-generated QR
7 Confirm & submit Done — summary + POST /api/bookings

Also done: n8n webhooks (#8), admin dashboard at /dashboard (#11), Authentik OIDC login (#10, needs Authentik-side setup), client portal at /my-bookings (#13). Auth lives in api/auth.js (zero-dep OIDC + HMAC cookie sessions); admin routes accept an OIDC admin session or the legacy ADMIN_SECRET bearer.

Booking JS Functions (in src/index.html)

Function Purpose
openBooking() Reset state, render all cards, show modal
closeBooking() Hide modal, restore scroll
goToStep(n) Activate step panel, update step bar
renderSessionTypeCards() Builds step 2 from siteConfig.booking.sessionTypes
renderSessionLengthCards() Builds step 3 with pricing from siteConfig.booking.pricing
renderLocationCards() Builds step 4 from siteConfig.locations
selectOption(card, field) Generic card select; handles "other" field show/hide
selectLocation(id) Location card select + toggle detail panel
validateStep(step) Returns bool; validates "other" text on step 2
populateSummary() Fills step 7 confirm rows from bookingState + config labels
submitBooking() POST /api/bookings; shows success state

Pending Work

All original wizard issues (#3#13) are code-complete. Remaining items — mostly infrastructure setup, secrets, and content — are catalogued in docs/BACKLOG-2026-07-16.md and should be transferred to Gitea issues.

Issues are tracked at: https://git.jerodrigged.com/jhodgkin/homelab/issues