Files
mc_cars_gmbh_infraestructure/ARQUITECTURE.md
T

275 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (<host>)
│ │
:55580 (nginx) :55521 (Kong) :55530 (Studio)
│ │ │
┌──────┴──────┐ │ │
│ web │ │ │
│ (static) │ │ │
└─────────────┘ │ │
│ │
┌─────────────────────────┼─────────────────────────┤
│ │ │ │ │
/auth/v1 /rest/v1 /realtime/v1 /storage/v1 /pg/
│ │ │ │ │
┌────┴───┐ ┌─────┴────┐ ┌────┴────┐ ┌─────┴────┐ ┌────┴───┐
│ gotrue │ │ postgrest│ │ realtime│ │ storage │ │ meta │
└────┬───┘ └─────┬────┘ └────┬────┘ └─────┬────┘ └────┬───┘
│ │ │ │ │
└──────────┬─┴────────────┴─────────────┴───────────┘
┌────┴─────┐ ┌───────────┐
│ postgres │ │ imgproxy │
│ (wal: │ │ (resize) │
│ logical) │ └───────────┘
└──────────┘ ▲
│ │
/mnt/.../data/db /mnt/.../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 → :55580/index.html (nginx, static)
browser → :55521/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({...})
→ :55521/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
→ :55521/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}})
→ :55521/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)
→ :55521/storage/v1/object/vehicle-photos/<path>
→ Kong → storage-api → /mnt/.../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: `55521:8000` (8000 blocked by Docker Desktop's wslrelay on Windows; `555xx` range avoids collisions on busy Docker hosts).
---
## 6. Frontend architecture
```
frontend/
├── Dockerfile (legacy, not used in Portainer deploy)
├── 99-config.sh (entrypoint: 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 by `99-config.sh` (bind-mounted into `/docker-entrypoint.d/`) from `$SUPABASE_URL` and `$SUPABASE_ANON_KEY` only. The service_role key is never mounted into the web container.
- The `web` service uses `nginx:1.27-alpine` directly with bind-mounted files (no `build:` step). This is Portainer-compatible: updating the frontend is `git pull` + container restart.
- `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. Deployment
**Host root:** `/mnt/user/appdata/mc-cars`
All bind mounts in `docker-compose.yml` use absolute paths under that root. The compose has no `build:` steps — every image is pulled from a registry, making it Portainer-compatible.
What's state (under `/mnt/user/appdata/mc-cars/data/`):
- `data/db/` — Postgres cluster
- `data/storage/` — object bucket files
What's code/config (committed):
- `docker-compose.yml`, `.env`, `supabase/`, `frontend/`, `AGENT.md`, `ARQUITECTURE.md`, `README.md`, `.gitattributes`
**Portainer deployment:**
1. Clone repo to `/mnt/user/appdata/mc-cars`
2. `chmod +x` the `.sh` files
3. `mkdir -p data/{db,storage}`
4. Portainer → Stacks → Add stack → paste compose + env vars → Deploy
**Nginx Proxy Manager (single public domain):**
- Proxy `/``mccars-web:80` (or `<host>:55580`)
- Custom locations `/auth/v1/`, `/rest/v1/`, `/realtime/v1/`, `/storage/v1/``mccars-kong:8000` (or `<host>:55521`)
- Do **not** expose `/pg/` or Studio publicly
- Update `.env` URLs to `https://cars.yourdomain.com`
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. Wire real SMTP for password-reset mail (`SMTP_*`).
4. Back up `/mnt/user/appdata/mc-cars/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.