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:
+259
@@ -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.
|
||||
Reference in New Issue
Block a user