# AGENT.md – Operational notes for working on MC Cars

A compact field guide for anyone (human or AI) making changes to this stack. Skim this first.

---

## 1. The deployment model

1. **Portainer-native.** No `build:` steps. Every service pulls a pre-built image. The `web` service uses `nginx:1.27-alpine` with bind-mounted static files.
2. **Absolute host paths.** All bind mounts point to `/mnt/user/appdata/mc-cars/...`. Runtime state lives under `/mnt/user/appdata/mc-cars/data/`.
3. **No named volumes.** Wiping data means `rm -rf /mnt/user/appdata/mc-cars/data/db` — not `docker volume rm`.
4. **Secrets live in `.env`**; the `.env` ships with dev defaults. Any non-local deployment must rotate every key (see §6).
5. **External ports are in the `555xx` range** to avoid collisions on a busy host (`55521` Kong, `55530` Studio, `55532` Postgres, `55543` Kong TLS, `55580` web).

---

## 2. Migration lifecycle

Migrations come in two flavours:

### First-boot only (runs iff `/mnt/user/appdata/mc-cars/data/db` is empty)
- `supabase/migrations/00-run-init.sh` — wrapper that runs `01-init.sql` via `psql` so we can pre-create Supabase service roles (`anon`, `authenticated`, `service_role`, `authenticator`, `supabase_auth_admin`, `supabase_storage_admin`) that the official images otherwise assume already exist on their `supabase/postgres` image. We use plain `postgres:15-alpine`, so we create them ourselves.
- `supabase/migrations/01-init.sql` — vehicles table, RLS, bucket row, seed demo vehicles.

Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on an **empty** data dir. To re-run: wipe `/mnt/user/appdata/mc-cars/data/db`.

### Post-boot (every time, idempotent)
- `supabase/migrations/post-boot.sql` — seeds the admin `auth.users` row with `must_change_password=true`, creates the `vehicle-photos` bucket row if missing. Parameterized: `psql -v admin_email=... -v admin_password=...`.
- `supabase/migrations/02-leads.sql` — leads + customers + RPCs + `supabase_realtime` publication. Uses `create if not exists`, `create or replace`, and `do $$ ... $$` with exists-checks so every object is idempotent.
- `supabase/migrations/08-backend-pricing-and-security.sql` — `calculate_price` RPC (public, read-only pricing), refactored `create_lead` RPC (accepts no price params, computes server-side), unique partial indexes on `lead_attachments` (max 1 id_document + 1 income_proof per lead), hardened storage RLS (anon INSERT-only on customer-documents, admin SELECT+INSERT only).
- `supabase/migrations/09-site-settings.sql` — `site_settings` key-value table + RLS (public SELECT, admin full CRUD) + seed row `hero_image_url = '/images/ferrari-main-car.png'`.

Both are run by the `post-init` service, which gates on `auth.users` and `storage.buckets` being queryable before touching anything.

**Adding a new migration:**
- If it's schema the app can't start without on a fresh DB → extend `01-init.sql`.
- If it's ongoing / idempotent → new file `NN-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint in `docker-compose.yml`.
- Current numbering: `01`, `02`, `08`, `09`. Use the next available number for new migrations.

---

## 3. Roles, RLS, and the mental model

| Role              | How obtained                       | Can do                               |
| ----------------- | ---------------------------------- | ------------------------------------ |
| `anon`            | Anon JWT (shipped to browser)      | `select` on active `vehicles`; `insert` on `leads` only; `select` on `site_settings`; `insert` on `customer-documents` bucket; execute `calculate_price` and `create_lead` RPCs |
| `authenticated`   | JWT from `signInWithPassword`      | Full CRUD on vehicles/leads/customers/sales_orders/site_settings, `execute` RPCs, storage uploads |
| `service_role`    | Service JWT (never in browser)     | Bypasses RLS                          |

- Anon **cannot** read leads. The booking form is fire-and-forget by design. An abuser submitting with a stolen anon key sees only HTTP 201 with no row data.
- Anon **cannot** read/delete from `customer-documents` bucket — only INSERT (upload). Admin can SELECT + INSERT but not DELETE (prevents evidence destruction).
- RPCs are `SECURITY INVOKER`. They run as the caller, so `auth.uid()` returns the actual admin; RLS still applies.
- `qualify_lead` is idempotent — calling it twice returns the existing customer row instead of raising.
- `create_lead` computes prices server-side via `calculate_price` — no price params accepted from client (prevents price-tampering).

---

## 4. Realtime wiring

- Postgres command line forces `wal_level=logical`, `max_wal_senders=10`, `max_replication_slots=10`. Without `logical`, `supabase/realtime` crash-loops.
- `supabase/realtime:v2.30.23` connects as **`DB_USER=postgres`** (not `supabase_admin`). We're on the plain `postgres:15-alpine` image; the `supabase_admin` role does not exist.
- `SECRET_KEY_BASE` must be ≥ 64 chars; the hardcoded value in `docker-compose.yml` is a dev default — rotate it on real deploys.
- Replica identity `full` is set on leads/customers/vehicles so updates broadcast the complete row, not just the PK.
- Kong route `/realtime/v1/` → `http://realtime:4000/socket` (websocket upgrade headers handled by Kong's default config).

---

## 5. Kong traps

- On Windows/WSL2, Docker Desktop's `wslrelay` intercepts `127.0.0.1:8000`. On the production host Kong is exposed on **`55521`** (container `8000`).
- On a busy Unraid/Docker host all MC Cars ports live in the `555xx` range to avoid collisions.
- Kong needs `KONG_PLUGINS: bundled,request-transformer,cors,key-auth,acl,basic-auth`. Omitting `acl` breaks Studio.
- `kong.yml` has one `consumer` per role with `acl` groups; plugins `key-auth` + `acl` gate which `apikey` (anon vs service_role) can reach which route.

---

## 6. Password rotation & admin bootstrap

The `.env` `ADMIN_PASSWORD` is a one-shot **bootstrap** seed. On first login:

1. `admin.js` reads `user.user_metadata.must_change_password`.
2. If true, the UI shows the "Passwort setzen" screen and refuses to proceed.
3. Submit calls `supabase.auth.updateUser({ password, data: { must_change_password: false } })`.
4. The frontend also rejects `newPassword === loginPassword` before sending.

Result: the live admin password differs from `.env` from the very first session. The `.env` value is useful only for a brand-new `./data/db`.

Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.

---

## 7. Frontend conventions

- **Only** the anon key leaves the server. `config.js` is generated at container start via an inline `entrypoint:` command in `docker-compose.yml` (writes `window.MCCARS_CONFIG={SUPABASE_URL,SUPABASE_ANON_KEY}`). The service_role key is never mounted into the `web` container.
- **No entrypoint script file.** An earlier design used `frontend/99-config.sh` bind-mounted into `/docker-entrypoint.d/`. Abandoned because Unraid's filesystem does not preserve Unix execute bits on bind mounts — the script was silently skipped. The inline `entrypoint:` in compose needs no file permissions.
- `frontend/config.js` is **git-ignored** (generated at runtime, would commit stale `localhost` values otherwise).
- `index.html` / `admin.html` load `config.js` with a `?v=<timestamp>` query string (generated by a small inline `document.write` snippet) to defeat both browser and proxy caching.
- The `web` service uses `nginx:1.27-alpine` directly (no `build:`). Static files and `nginx.conf` are bind-mounted. Updating the frontend is `git pull` + `docker compose up -d --force-recreate web`.
- `frontend/app.js` (public) creates a Supabase client with `persistSession: false` — the public site never needs a session.
- `frontend/admin.js` uses `persistSession: true, storageKey: "mccars.auth"` and a single realtime channel `mccars-admin` that subscribes to leads/customers/vehicles.
- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, **Einstellungen**. The Einstellungen (Settings) tab manages site-wide configuration (currently: hero image upload).
- `app.js` calls `calculate_price` RPC for sidebar pricing display — **no client-side price computation**.
- `app.js` calls `create_lead` RPC for form submission — prices are computed server-side and stored on the lead.
- `app.js` at boot loads `hero_image_url` from `site_settings` and applies it via CSS variable `--hero-bg` on `.hero`. The `styles.css` fallback is `url('images/ferrari-main-car.png')`.
- `app.js` writes `vehicle_id` (uuid) **and** denormalized `vehicle_label` ("BMW M3") into the lead at submit time, so the admin UI renders even if the vehicle is later deleted.

---

## 8. Common failure modes

| Symptom                                                 | Cause / Fix                                                      |
| ------------------------------------------------------- | ---------------------------------------------------------------- |
| `auth` container restart loop, "role supabase_auth_admin does not exist" | `00-run-init.sh` didn't run. Wipe `/mnt/user/appdata/mc-cars/data/db`, retry. |
| `realtime` container exits, "wal_level must be logical" | `db` `command:` missing `wal_level=logical`.                     |
| 502 on `/realtime/v1/` through Kong                     | Missing `acl` plugin in `KONG_PLUGINS`, or realtime not listed in `kong.yml` consumers. |
| Admin login works but Leads tab is empty                | RLS: session is `anon` instead of `authenticated`. Check that `signInWithPassword` succeeded and token is attached to subsequent requests. |
| Booking form submits but no row appears                 | anon lacks `INSERT` grant on `public.leads`. Check `02-leads.sql` ran (see `post-init` logs). |
| `.sh` files reject with `\r: command not found`         | CRLF line endings. `dos2unix` or re-save as LF.                  |
| Port 8000 "connection refused" on Windows               | Docker Desktop wslrelay. Use `55521` (production port).          |
| `config.js` not generated, website uses fallback URL    | Unraid doesn't preserve execute bits — entrypoint script skipped. Fixed: `config.js` is now written by an inline `entrypoint:` command in docker-compose, no file needed. |
| Website loads but API calls fail ("Failed to fetch")    | `config.js` cached by NPM or browser with old `localhost` URL. The `?v=timestamp` cache-buster on the `<script>` tag prevents this. Hard-refresh or check NPM "Cache Assets" toggle. |
| Git pull fails with "Your local changes would be overwritten" | `.env` was modified on the NAS. Use `git checkout -- .env && git pull`, then re-apply the two URL lines with `sed`. |
| `calculate_price` returns PGRST202 "Could not find the function" | Migration `08-backend-pricing-and-security.sql` not applied. Check `post-init` logs or run it manually. |
| Hero image not changing after admin upload | Browser/CDN caching the old image. The URL includes a unique path (`site/hero.ext`) with upsert — hard-refresh the public page. |
| `create_lead` fails with "function does not exist" | Same as calculate_price — migration 08 not applied. |

---

## 9. How to verify a working stack

```bash
docker compose ps                                            # all services up (post-init exited 0)
curl http://localhost:55521/rest/v1/vehicles?select=brand -H "apikey: $ANON" # 6 demo cars
curl -X POST http://localhost:55521/rest/v1/rpc/calculate_price \
  -H "apikey: $ANON" -H "Content-Type: application/json" \
  -d '{"p_vehicle_id":"<uuid>","p_date_from":"2025-01-06","p_date_to":"2025-01-10"}'  # pricing JSON
curl http://localhost:55521/rest/v1/site_settings?select=value&key=eq.hero_image_url \
  -H "apikey: $ANON"  # returns hero image URL
```

In the browser:
1. Open http://\<host\>:55580, verify the hero image loads (dynamic from settings).
2. Submit the booking form — sidebar shows server-computed pricing.
3. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
4. Rotate the password when prompted.
5. The lead you submitted appears in "Aktive Leads" — in real time, with pricing columns.
6. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
7. Go to **Einstellungen** tab. Upload a new hero image. Verify it appears on the public site after refresh.
8. Open Supabase Studio at http://\<host\>:55530 and confirm `site_settings`, `leads` pricing columns, `customers.lead_id` FK.

---

## 10. Things explicitly NOT in this stack

- No Google Analytics / Google Reviews widget.
- No "Anmelden/Registrieren" on the public site. Admin access is intentionally unlinked.
- No logflare / analytics.
- No edge-functions container — RPCs live in Postgres.
- No client-side price calculation — all pricing is server-side via `calculate_price` RPC.
