Files
mc_cars_gmbh_infraestructure/ARQUITECTURE.md
T
Lago 61517879e1 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.
2026-04-17 17:50:57 +02:00

12 KiB
Raw Blame History

ARQUITECTURE.md MC Cars stack

A thorough walkthrough of how the MC Cars system is wired. Complements AGENT.md (operational notes) and 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: corskey-authacl → 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.