From 3298efe54b57daa06f8f9cc7abecc48a67de065f Mon Sep 17 00:00:00 2001 From: LagoESP Date: Wed, 29 Apr 2026 20:39:21 +0200 Subject: [PATCH] feat: implement server-side pricing calculation and add site settings management Co-authored-by: Copilot --- AGENT.md | 39 ++++-- ARQUITECTURE.md | 159 +++++++++++++++++++---- README.md | 21 +-- docker-compose.local.yml | 1 + docker-compose.yml | 2 + frontend/admin.html | 17 +++ frontend/admin.js | 45 +++++++ frontend/app.js | 8 ++ frontend/i18n.js | 6 + frontend/styles.css | 2 +- supabase/migrations/09-site-settings.sql | 32 +++++ 11 files changed, 288 insertions(+), 44 deletions(-) create mode 100644 supabase/migrations/09-site-settings.sql diff --git a/AGENT.md b/AGENT.md index 8739203..811a973 100644 --- a/AGENT.md +++ b/AGENT.md @@ -27,12 +27,15 @@ Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on a ### 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 `03-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint. +- 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. --- @@ -40,13 +43,15 @@ Both are run by the `post-init` service, which gates on `auth.users` and `storag | Role | How obtained | Can do | | ----------------- | ---------------------------------- | ------------------------------------ | -| `anon` | Anon JWT (shipped to browser) | `select` on active `vehicles`; `insert` on `leads` only | -| `authenticated` | JWT from `signInWithPassword` | Full CRUD on vehicles/leads/customers, `execute` RPCs | +| `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). --- @@ -93,6 +98,10 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`. - 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. --- @@ -111,6 +120,9 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`. | `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 `