feat: implement server-side pricing calculation and add site settings management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+134
-25
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user