feat: Add Supabase configuration and migrations for MC Cars application

- Create Kong declarative configuration for routing and authentication.
- Implement initialization script to set up the database.
- Add SQL migration for initializing roles, schemas, and seeding vehicle data.
- Create leads and customers tables with appropriate policies and functions for CRM.
- Seed admin user and configure storage bucket with RLS policies.
This commit is contained in:
Lago
2026-04-17 17:50:57 +02:00
commit 61517879e1
23 changed files with 3673 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
# ARQUITECTURE.md MC Cars stack
A thorough walkthrough of how the MC Cars system is wired. Complements [AGENT.md](AGENT.md) (operational notes) and [README.md](README.md) (how to run).
---
## 1. High-level diagram
```
Browser (localhost)
│ │
:8080 (nginx) :54321 (Kong) :3000 (Studio)
│ │ │
┌──────┴──────┐ │ │
│ web │ │ │
│ (static) │ │ │
└─────────────┘ │ │
│ │
┌─────────────────────────┼─────────────────────────┤
│ │ │ │ │
/auth/v1 /rest/v1 /realtime/v1 /storage/v1 /pg/
│ │ │ │ │
┌────┴───┐ ┌─────┴────┐ ┌────┴────┐ ┌─────┴────┐ ┌────┴───┐
│ gotrue │ │ postgrest│ │ realtime│ │ storage │ │ meta │
└────┬───┘ └─────┬────┘ └────┬────┘ └─────┬────┘ └────┬───┘
│ │ │ │ │
└──────────┬─┴────────────┴─────────────┴───────────┘
┌────┴─────┐ ┌───────────┐
│ postgres │ │ imgproxy │
│ (wal: │ │ (resize) │
│ logical) │ └───────────┘
└──────────┘ ▲
│ │
./data/db ./data/storage
```
Dashed lifetime services (exit 0 once done): `post-init`.
Network: single user-defined bridge `mccars`. No host networking, no ingress controller — everything is container-to-container DNS (`db`, `auth`, `kong`, …).
---
## 2. Request flows
### 2.1 Public visitor reads cars
```
browser → :8080/index.html (nginx, static)
browser → :54321/rest/v1/vehicles?select=*&is_active=eq.true
→ Kong → rest (PostgREST) → postgres
→ RLS: "vehicles_select_active" (for anon, is_active=true only)
← JSON
```
No auth header except the anon `apikey`. Kong strips the key and forwards with the service's internal Auth context.
### 2.2 Public visitor submits booking
```
browser → supabase.from('leads').insert({...})
→ :54321/rest/v1/leads (POST, apikey=anon)
→ PostgREST → postgres
→ RLS "leads_anon_insert": insert allowed, select denied
← 201 (no body returned to anon)
```
The frontend never reads leads back. If the INSERT fails (validation, RLS), the UI shows a localized failure string.
### 2.3 Admin login
```
browser(admin.html) → supabase.auth.signInWithPassword
→ :54321/auth/v1/token?grant_type=password
→ Kong → gotrue → postgres(auth.users)
← access_token (JWT, aud=authenticated), refresh_token
← user.raw_user_meta_data.must_change_password (true on bootstrap)
if must_change_password:
user blocked in "Passwort setzen" screen
supabase.auth.updateUser({password, data:{must_change_password:false}})
→ :54321/auth/v1/user (PUT with Bearer access_token)
→ gotrue updates auth.users
```
### 2.4 Admin qualifies a lead
```
admin.js → supabase.rpc('qualify_lead', {p_lead_id, p_notes})
→ PostgREST → postgres.public.qualify_lead(uuid,text)
(SECURITY INVOKER, role=authenticated, auth.uid()=admin)
BEGIN
SELECT ... FOR UPDATE on leads
UPDATE leads SET status='qualified', is_active=false, ...
INSERT INTO customers (lead_id, name, email, phone, ...)
COMMIT
← customers row
Realtime publication fires:
postgres_changes(table=leads, event=UPDATE, ...) → admin.js channel
postgres_changes(table=customers, event=INSERT, ...) → admin.js channel
→ admin UI reloads tabs, badges update
```
### 2.5 Photo upload
```
admin.js → supabase.storage.from('vehicle-photos').upload(path, file)
→ :54321/storage/v1/object/vehicle-photos/<path>
→ Kong → storage-api → ./data/storage
← public URL served via imgproxy for on-the-fly transforms
admin.js → UPDATE vehicles SET photo_url = <public url>
```
Bucket is public-read, write-authenticated, MIME-restricted to `image/*`, 50 MB cap.
---
## 3. Database layout
```
postgres (database = "postgres")
├── auth schema (GoTrue)
├── storage schema (Storage API)
├── _realtime schema (Realtime bookkeeping)
└── public schema
├── vehicles (fleet)
├── leads (form submissions)
├── customers (spawned from qualified leads)
├── tg_touch_updated_at() trigger fn
├── qualify_lead() RPC
├── disqualify_lead() RPC
└── reopen_lead() RPC
```
### 3.1 `public.vehicles`
- `id uuid pk`
- `brand`, `model`, `power_hp`, `top_speed_kmh`, `acceleration`, `seats`, `daily_price_eur`
- `sort_order`, `location`
- `description_de`, `description_en`
- `photo_url`, `photo_path` (storage object key for deletion)
- `is_active bool` — public visibility flag
- `created_at`, `updated_at`
### 3.2 `public.leads`
- `id uuid pk`
- `name`, `email`, `phone`
- `vehicle_id → vehicles(id) ON DELETE SET NULL`
- `vehicle_label text` — denormalized at submit; survives vehicle deletion
- `date_from`, `date_to`, `message`
- `status check in ('new','qualified','disqualified')` default `new`
- `is_active bool` default `true` — filter for Active/Inactive tabs
- `admin_notes text`
- `source text` default `'website'`
- `qualified_at`, `qualified_by → auth.users(id)`
### 3.3 `public.customers`
- `id uuid pk`
- `lead_id → leads(id) ON DELETE RESTRICT` + `unique index` (exactly one customer per lead)
- `name`, `email`, `phone`
- `first_contacted_at`
- `notes`
- `status check in ('active','inactive')`
- `created_by → auth.users(id)`
### 3.4 RLS matrix
| Table | anon | authenticated |
| ---------- | -------- | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| customers | denied | full CRUD |
### 3.5 RPCs
| Function | Role required | Semantics |
| --------------------------------- | --------------- | ------------------------------------------------------------------------- |
| `qualify_lead(uuid, text)` | authenticated | Locks lead row, flips to `qualified`+inactive, inserts matching customer. Idempotent: returns existing customer on second call. |
| `disqualify_lead(uuid, text)` | authenticated | Marks lead `disqualified`+inactive, stores notes. |
| `reopen_lead(uuid)` | authenticated | Resets lead to `new`+active, deletes the spawned customer if any. |
All three are `SECURITY INVOKER`, so `auth.uid()` is the live admin and RLS applies.
---
## 4. Realtime
- Postgres runs with `wal_level=logical`, `max_wal_senders=10`, `max_replication_slots=10`.
- `supabase/realtime:v2.30.23` on internal `:4000`, DB_USER=`postgres` (no `supabase_admin` on plain postgres).
- Publication `supabase_realtime` dynamically adds `leads`, `customers`, `vehicles`.
- `replica identity full` on all three — delivers the full row on updates (needed so the admin UI can show changed fields without re-fetching).
- Frontend joins a single channel (`mccars-admin`) and attaches three `postgres_changes` handlers.
---
## 5. API gateway (Kong)
Declarative config in `supabase/kong.yml`. One consumer per role:
| Consumer | `apikey` | ACL groups | Routes accessible |
| ------------- | ------------------ | ---------- | ---------------------------------------------------------- |
| anon | `ANON_KEY` | `anon` | `/auth/v1/*`, `/rest/v1/*` (rls-gated), `/realtime/v1/*`, `/storage/v1/object/public/*` |
| service_role | `SERVICE_ROLE_KEY` | `admin` | all of the above + `/pg/*` (postgres-meta), write paths |
Plugins pipeline: `cors``key-auth``acl` → proxy. Public routes (storage public objects) are whitelisted without `key-auth`.
Host port mapping: `54321:8000` (8000 blocked by Docker Desktop's wslrelay on Windows).
---
## 6. Frontend architecture
```
frontend/
├── Dockerfile (nginx:alpine, writes config.js at boot)
├── nginx.conf (serves /, gzip, cache headers)
├── index.html app.js (public site, anon key, persistSession:false)
├── admin.html admin.js (admin CRM, persistSession:true)
├── i18n.js (DE/EN)
├── styles.css (copper/charcoal palette)
├── impressum.html
└── datenschutz.html
```
- `config.js` is generated at container start (`envsubst`) from `SUPABASE_URL` and `SUPABASE_ANON_KEY` only. The service_role key is never mounted into the web container.
- `admin.js` is ES modules, imports `@supabase/supabase-js` from `esm.sh` (CDN, pinned version). One Supabase client per page.
- State lives in a single `state` object. Admin re-renders the active tab on realtime events.
- Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
---
## 7. Portability & deployment
**The contract:** everything under `c:\Coding\MC Cars GmbH\` (or whatever you rename it to) is the full stack. Copy it anywhere Docker runs, bring up the stack.
What's state (under `./data/`):
- `./data/db/` — Postgres cluster
- `./data/storage/` — object bucket files
What's code/config (committed):
- `docker-compose.yml`, `.env` (dev defaults), `supabase/`, `frontend/`, `AGENT.md`, `ARQUITECTURE.md`, `README.md`
For real deployments:
1. Generate a new `JWT_SECRET` and matching `ANON_KEY` / `SERVICE_ROLE_KEY` (see Supabase self-hosting docs).
2. Set a strong `POSTGRES_PASSWORD`, `ADMIN_PASSWORD`.
3. Put Kong behind a TLS reverse-proxy (Caddy/Traefik/nginx) and set `SITE_URL`, `API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL` to the public https URLs.
4. Wire real SMTP for password-reset mail (`SMTP_*`).
5. Back up `./data/` on a schedule.
---
## 8. Security model summary
- **No admin keys in the browser, ever.** Only the anon key reaches the client.
- **Signup is disabled** (`GOTRUE_DISABLE_SIGNUP=true`). New admins are provisioned by inserting into `auth.users` via `post-boot.sql` or Studio.
- **Bootstrap password is forced out** on first login (`must_change_password` metadata). Frontend refuses to reuse the bootstrap value.
- **RLS is the single source of authorization.** The admin UI calling `.from('leads').select('*')` works only because the authenticated policy allows it; without a valid Bearer JWT the same query returns zero rows.
- **RPCs are `SECURITY INVOKER`** — no role-escalation surface.
- **Storage bucket** is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.