feat: implement server-side pricing calculation and add site settings management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LagoESP
2026-04-29 20:39:21 +02:00
parent b0bea0bef1
commit 3298efe54b
11 changed files with 288 additions and 44 deletions
+134 -25
View File
@@ -58,14 +58,17 @@ No auth header except the anon `apikey`. Kong strips the key and forwards with t
### 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)
browser → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
:55521/rest/v1/rpc/create_lead (POST, apikey=anon)
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price() → computes pricing
→ INSERT INTO leads (with pricing columns)
← lead uuid
```
The frontend never reads leads back. If the INSERT fails (validation, RLS), the UI shows a localized failure string.
No price parameters are accepted from the client — all pricing is computed server-side. This prevents price-tampering. The frontend never reads leads back. If the RPC fails (validation, RLS), the UI shows a localized failure string.
### 2.3 Admin login
@@ -114,6 +117,53 @@ admin.js → UPDATE vehicles SET photo_url = <public url>
Bucket is public-read, write-authenticated, MIME-restricted to `image/*`, 50 MB cap.
### 2.6 Server-side price calculation
```
app.js → supabase.rpc('calculate_price', {p_vehicle_id, p_date_from, p_date_to})
→ :55521/rest/v1/rpc/calculate_price (POST, apikey=anon)
→ PostgREST → postgres.public.calculate_price(uuid, date, date)
← jsonb {daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur,
deposit_eur, total_days, weekday_count, weekend_day_count}
```
Pricing logic lives **only** in the database — the frontend never computes prices. `create_lead` also calls `calculate_price` internally to store an immutable pricing snapshot on the lead row.
### 2.7 Lead submission (server-computed prices)
```
app.js → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price → stores pricing on lead row
← lead uuid
```
No price parameters are accepted from the client. This prevents price-tampering attacks.
### 2.8 Site settings & configurable hero image
```
app.js → supabase.from('site_settings').select('value').eq('key','hero_image_url').single()
→ :55521/rest/v1/site_settings?key=eq.hero_image_url&select=value
→ RLS: anon SELECT allowed
← { value: '/images/ferrari-main-car.png' }
→ app.js sets CSS variable --hero-bg on .hero element
```
Admin upload flow:
```
admin.js → supabase.storage.from('vehicle-photos').upload('site/hero.ext', file, {upsert:true})
→ storage API writes to bucket
← public URL
admin.js → supabase.from('site_settings').upsert({key:'hero_image_url', value: publicUrl})
→ PostgREST → UPDATE site_settings
← 200
```
The CSS uses `var(--hero-bg, url('images/ferrari-main-car.png'))` so the default hero image is shown until JavaScript loads and overrides it.
---
## 3. Database layout
@@ -124,13 +174,26 @@ postgres (database = "postgres")
├── 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
├── vehicles (fleet)
├── leads (form submissions, server-computed pricing)
├── lead_attachments (id_document, income_proof per lead)
├── customers (spawned from qualified leads)
├── customer_attachments (admin-uploaded docs for customers)
├── sales_orders (rental orders linked to customer+lead)
── sales_order_attachments
├── site_settings (key-value config, e.g. hero_image_url)
├── tg_touch_updated_at() trigger fn
├── calculate_price() RPC (public, read-only pricing)
├── create_lead() RPC (server-side price computation)
├── qualify_lead() RPC
├── disqualify_lead() RPC
├── reopen_lead() RPC
├── sales_order_toggle_kaution() RPC
├── sales_order_toggle_rental() RPC
├── sales_order_toggle_complete() RPC
├── customer_update_private_notes() RPC
├── sales_order_update_private_notes() RPC
└── sales_order_upload_attachment() RPC
```
### 3.1 `public.vehicles`
@@ -148,10 +211,12 @@ postgres (database = "postgres")
- `vehicle_id → vehicles(id) ON DELETE SET NULL`
- `vehicle_label text` — denormalized at submit; survives vehicle deletion
- `date_from`, `date_to`, `message`
- Pricing (server-computed, immutable): `daily_subtotal`, `weekend_subtotal`, `subtotal_eur`, `vat_eur`, `total_eur`, `deposit_eur`, `total_days`, `weekday_count`, `weekend_day_count`
- `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'`
- `ip_address inet`, `ip_country text` — geolocation info from submission
- `qualified_at`, `qualified_by → auth.users(id)`
### 3.3 `public.customers`
@@ -159,25 +224,65 @@ postgres (database = "postgres")
- `lead_id → leads(id) ON DELETE RESTRICT` + `unique index` (exactly one customer per lead)
- `name`, `email`, `phone`
- `first_contacted_at`
- `notes`
- `notes`, `private_notes`
- `status check in ('active','inactive')`
- `created_by → auth.users(id)`
### 3.4 RLS matrix
### 3.4 `public.lead_attachments`
- `id uuid pk`
- `lead_id → leads(id) ON DELETE CASCADE`
- `bucket`, `file_path`, `file_name`, `mime_type`
- `kind check in ('id_document', 'income_proof', 'other')`
- Unique partial index: max 1 `id_document` + 1 `income_proof` per lead
| Table | anon | authenticated |
| ---------- | -------- | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| customers | denied | full CRUD |
### 3.5 `public.sales_orders`
- `id uuid pk`
- `customer_id → customers(id)`, `lead_id → leads(id)`
- `order_number`, `private_notes`
- `kaution_paid`, `rental_paid`, `rental_complete` (booleans + timestamps)
- Pricing snapshot: `daily_subtotal`, `weekend_subtotal`, `subtotal_eur`, `vat_eur`, `total_eur`, `deposit_eur`
- `total_days`, `weekday_count`, `weekend_day_count`, `date_from`, `date_to`, `vehicle_label`
### 3.5 RPCs
### 3.6 `public.site_settings`
- `key text pk` — setting identifier (e.g. `hero_image_url`)
- `value text` — setting value (e.g. a public URL or path)
- `updated_at timestamptz`
- RLS: public SELECT for all, authenticated-only INSERT/UPDATE/DELETE
### 3.7 RLS matrix
| Table | anon | authenticated |
| ------------------ | ------------------ | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| lead_attachments | INSERT only | SELECT + INSERT |
| customers | denied | full CRUD |
| sales_orders | denied | full CRUD |
| site_settings | SELECT only | full CRUD |
### 3.8 Storage buckets
| Bucket | Public read | anon writes | authenticated writes | Notes |
| ------------------- | ----------- | ----------- | -------------------- | ----- |
| `vehicle-photos` | yes | no | INSERT (MIME `image/*`, 50 MB) | Fleet photos + site hero image |
| `customer-documents`| no | INSERT only | SELECT + INSERT | ID docs, income proofs |
Anon users may only `INSERT` into `customer-documents` (no SELECT/UPDATE/DELETE). Admin can SELECT + INSERT but not DELETE (prevents accidental evidence destruction).
### 3.9 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. |
| `calculate_price(uuid, date, date)` | anon | Read-only pricing calculator. Returns jsonb: `{daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count}`. Uses vehicle's `daily_price_eur`, applies 20% weekend surcharge, 19% VAT, 3× daily deposit. |
| `create_lead(...)` | anon | Creates a lead with server-computed pricing (calls `calculate_price` internally). Accepts: name, email, phone, vehicle_id, vehicle_label, date_from, date_to, message, source, ip_address, ip_country. No price params from client. |
| `qualify_lead(uuid, text)` | authenticated | Locks lead row, flips to `qualified`+inactive, inserts matching customer + sales_order with pricing snapshot. 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. |
| `sales_order_toggle_kaution(uuid)` | authenticated | Toggles kaution_paid boolean + timestamp on a sales order. |
| `sales_order_toggle_rental(uuid)` | authenticated | Toggles rental_paid boolean + timestamp. |
| `sales_order_toggle_complete(uuid)` | authenticated | Toggles rental_complete boolean + timestamp. |
| `customer_update_private_notes(uuid, text)` | authenticated | Updates private_notes field on a customer. |
| `sales_order_update_private_notes(uuid, text)` | authenticated | Updates private_notes field on a sales order. |
All three are `SECURITY INVOKER`, so `auth.uid()` is the live admin and RLS applies.
@@ -214,9 +319,9 @@ Host port mapping: `55521:8000` (8000 blocked by Docker Desktop's wslrelay on Wi
frontend/
├── nginx.conf (serves /, gzip, cache headers)
├── index.html app.js (public site, anon key, persistSession:false)
├── admin.html admin.js (admin CRM, persistSession:true)
├── admin.html admin.js (admin CRM + settings, persistSession:true)
├── i18n.js (DE/EN)
├── styles.css (copper/charcoal palette)
├── styles.css (copper/charcoal palette, CSS-variable hero image)
├── impressum.html
└── datenschutz.html
```
@@ -227,7 +332,9 @@ frontend/
- 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` + `docker compose up -d --force-recreate web`.
- `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.
- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, **Einstellungen** (site settings: hero image upload).
- Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
- Public site hero image is loaded dynamically from `site_settings` table at boot and applied via CSS variable `--hero-bg`. Fallback default is baked into `styles.css`.
---
@@ -334,4 +441,6 @@ For real deployments:
- **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.
- **Server-side pricing** — `create_lead` computes prices internally via `calculate_price`. No price parameters are accepted from the client, preventing price-tampering.
- **Document security** — `customer-documents` bucket allows anon INSERT only (upload), no SELECT/DELETE. Admin can SELECT + INSERT but not DELETE. Unique partial indexes enforce max 1 `id_document` + 1 `income_proof` per lead.
- **Storage bucket** (`vehicle-photos`) is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.