feat: implement server-side pricing calculation and add site settings management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 `<script>` tag prevents this. Hard-refresh or check NPM "Cache Assets" toggle. |
|
||||
| Git pull fails with "Your local changes would be overwritten" | `.env` was modified on the NAS. Use `git checkout -- .env && git pull`, then re-apply the two URL lines with `sed`. |
|
||||
| `calculate_price` returns PGRST202 "Could not find the function" | Migration `08-backend-pricing-and-security.sql` not applied. Check `post-init` logs or run it manually. |
|
||||
| Hero image not changing after admin upload | Browser/CDN caching the old image. The URL includes a unique path (`site/hero.ext`) with upsert — hard-refresh the public page. |
|
||||
| `create_lead` fails with "function does not exist" | Same as calculate_price — migration 08 not applied. |
|
||||
|
||||
---
|
||||
|
||||
@@ -119,22 +131,29 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
|
||||
```bash
|
||||
docker compose ps # all services up (post-init exited 0)
|
||||
curl http://localhost:55521/rest/v1/vehicles?select=brand -H "apikey: $ANON" # 6 demo cars
|
||||
curl -X POST http://localhost:55521/rest/v1/rpc/calculate_price \
|
||||
-H "apikey: $ANON" -H "Content-Type: application/json" \
|
||||
-d '{"p_vehicle_id":"<uuid>","p_date_from":"2025-01-06","p_date_to":"2025-01-10"}' # pricing JSON
|
||||
curl http://localhost:55521/rest/v1/site_settings?select=value&key=eq.hero_image_url \
|
||||
-H "apikey: $ANON" # returns hero image URL
|
||||
```
|
||||
|
||||
In the browser:
|
||||
1. Open http://\<host\>:55580, submit the booking form.
|
||||
2. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
|
||||
3. Rotate the password when prompted.
|
||||
4. The lead you submitted appears in "Aktive Leads" — in real time.
|
||||
5. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
|
||||
6. Open Supabase Studio at http://\<host\>:55530 and confirm the `customers.lead_id` FK matches.
|
||||
1. Open http://\<host\>:55580, verify the hero image loads (dynamic from settings).
|
||||
2. Submit the booking form — sidebar shows server-computed pricing.
|
||||
3. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
|
||||
4. Rotate the password when prompted.
|
||||
5. The lead you submitted appears in "Aktive Leads" — in real time, with pricing columns.
|
||||
6. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
|
||||
7. Go to **Einstellungen** tab. Upload a new hero image. Verify it appears on the public site after refresh.
|
||||
8. Open Supabase Studio at http://\<host\>:55530 and confirm `site_settings`, `leads` pricing columns, `customers.lead_id` FK.
|
||||
|
||||
---
|
||||
|
||||
## 10. Things explicitly NOT in this stack
|
||||
|
||||
- No n8n. Email automation is out of scope here; do it downstream.
|
||||
- No Google Analytics / Google Reviews widget.
|
||||
- No "Anmelden/Registrieren" on the public site. Admin access is intentionally unlinked.
|
||||
- No logflare / analytics.
|
||||
- No edge-functions container — RPCs live in Postgres.
|
||||
- No client-side price calculation — all pricing is server-side via `calculate_price` RPC.
|
||||
|
||||
+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.
|
||||
|
||||
@@ -41,7 +41,7 @@ cd /mnt/user/appdata/mc-cars
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`). Give it 30–60 s to settle.
|
||||
First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`, `08-backend-pricing-and-security.sql`, `09-site-settings.sql`). Give it 30–60 s to settle.
|
||||
|
||||
### Stop / reset
|
||||
|
||||
@@ -74,9 +74,12 @@ The admin is seeded with `must_change_password = true` in `raw_user_meta_data`.
|
||||
## Data model
|
||||
|
||||
- `public.vehicles` — fleet, public-readable where `is_active`.
|
||||
- `public.leads` — booking form submissions. `anon` may `INSERT` only; `authenticated` has full CRUD. Status: `new | qualified | disqualified`.
|
||||
- `public.leads` — booking form submissions with server-computed pricing. `anon` may `INSERT` only (via `create_lead` RPC); `authenticated` has full CRUD. Status: `new | qualified | disqualified`.
|
||||
- `public.lead_attachments` — ID documents and income proofs per lead. Max 1 of each enforced by unique partial index.
|
||||
- `public.customers` — created **only** by qualifying a lead. Hard FK `lead_id` preserves the audit link to the originating lead.
|
||||
- RPCs: `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only.
|
||||
- `public.sales_orders` — rental orders created during qualification, contain pricing snapshot.
|
||||
- `public.site_settings` — key-value settings table (e.g. `hero_image_url`). Publicly readable, admin-writable.
|
||||
- RPCs: `calculate_price(uuid, date, date)` (public pricing), `create_lead(...)` (server-side submission), `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only (except calculate_price and create_lead which are anon-accessible).
|
||||
- Realtime: `supabase_realtime` publication broadcasts inserts/updates on leads, customers, vehicles.
|
||||
|
||||
## Environment: two variables per deployment
|
||||
@@ -166,16 +169,18 @@ MC Cars/
|
||||
│ ├── 00-run-init.sh # creates supabase service roles
|
||||
│ ├── 01-init.sql # vehicles + bucket + seed cars
|
||||
│ ├── post-boot.sql # admin user (must_change_password) + bucket row
|
||||
│ └── 02-leads.sql # leads, customers, RPCs, realtime publication
|
||||
│ ├── 02-leads.sql # leads, customers, RPCs, realtime publication
|
||||
│ ├── 08-backend-pricing-and-security.sql # calculate_price RPC, refactored create_lead, document security
|
||||
│ └── 09-site-settings.sql # site_settings table + hero_image_url seed
|
||||
├── frontend/
|
||||
│ ├── nginx.conf
|
||||
│ ├── index.html # public DE/EN site, booking form -> leads
|
||||
│ ├── admin.html # auth-gated CRM
|
||||
│ ├── app.js
|
||||
│ ├── admin.js # realtime + qualify/disqualify + password change
|
||||
│ ├── admin.html # auth-gated CRM + settings panel
|
||||
│ ├── app.js # dynamic hero image, server-side pricing sidebar
|
||||
│ ├── admin.js # realtime + qualify/disqualify + password change + settings
|
||||
│ ├── config.js # generated at container start (git-ignored)
|
||||
│ ├── i18n.js
|
||||
│ ├── styles.css
|
||||
│ ├── styles.css # CSS-variable hero image with fallback
|
||||
│ ├── impressum.html
|
||||
│ └── datenschutz.html
|
||||
├── .gitattributes # enforces LF on .sh files
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
- ./supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro
|
||||
- ./supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro
|
||||
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||
|
||||
kong:
|
||||
volumes:
|
||||
|
||||
@@ -216,6 +216,7 @@ services:
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||
entrypoint: ["sh","-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -240,6 +241,7 @@ services:
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/06-admin-pricing-documents.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/07-sales-orders.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql
|
||||
echo "post-init done."
|
||||
restart: "no"
|
||||
networks: [mccars]
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<button class="tab" data-tab="customers" role="tab"><span data-i18n="adminCustomers">Kunden</span> <span id="customersBadge" class="tab-badge">0</span></button>
|
||||
<button class="tab" data-tab="orders" role="tab"><span data-i18n="adminTabOrderHistory">Bestellungen</span> <span id="ordersBadge" class="tab-badge">0</span></button>
|
||||
<button class="tab" data-tab="vehicles" role="tab" data-i18n="adminVehicles">Fahrzeuge</button>
|
||||
<button class="tab" data-tab="settings" role="tab" data-i18n="adminSettings">Einstellungen</button>
|
||||
</div>
|
||||
|
||||
<!-- LEADS -->
|
||||
@@ -238,6 +239,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<div class="tab-panel" id="tab-settings" style="display:none;">
|
||||
<div class="panel" style="max-width:600px;">
|
||||
<h2 data-i18n="adminSettings">Einstellungen</h2>
|
||||
<div class="admin-form">
|
||||
<label>
|
||||
<span data-i18n="adminHeroImage">Hauptbild (Hero-Bereich)</span>
|
||||
<div class="admin-photo-preview" id="heroPreview" style="aspect-ratio:21/9;"></div>
|
||||
<input type="file" id="heroImageInput" accept="image/jpeg,image/png,image/webp" />
|
||||
</label>
|
||||
<p class="muted" style="font-size:0.82rem;" data-i18n="adminHeroImageHint">JPG/PNG/WebP. Wird als Hintergrundbild im Hero-Bereich der Website angezeigt.</p>
|
||||
<p class="form-feedback" id="heroFeedback"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Order detail dialog -->
|
||||
|
||||
@@ -177,6 +177,7 @@ const tabPanels = {
|
||||
customers: document.querySelector("#tab-customers"),
|
||||
orders: document.querySelector("#tab-orders"),
|
||||
vehicles: document.querySelector("#tab-vehicles"),
|
||||
settings: document.querySelector("#tab-settings"),
|
||||
};
|
||||
let activeTab = "leads";
|
||||
|
||||
@@ -206,6 +207,7 @@ function renderActiveTab() {
|
||||
if (activeTab === "customers") renderCustomers();
|
||||
if (activeTab === "orders") renderOrders();
|
||||
if (activeTab === "vehicles") renderVehicles();
|
||||
if (activeTab === "settings") renderSettings();
|
||||
}
|
||||
|
||||
// Sub-tabs (active/inactive leads)
|
||||
@@ -1100,4 +1102,47 @@ leadDialog.addEventListener("close", onDialogClose);
|
||||
customerDialog.addEventListener("close", onDialogClose);
|
||||
orderDialog.addEventListener("close", onDialogClose);
|
||||
|
||||
// =========================================================================
|
||||
// SETTINGS
|
||||
// =========================================================================
|
||||
const heroPreview = document.querySelector("#heroPreview");
|
||||
const heroImageInput = document.querySelector("#heroImageInput");
|
||||
const heroFeedback = document.querySelector("#heroFeedback");
|
||||
|
||||
async function renderSettings() {
|
||||
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
|
||||
const url = data?.value || "/images/ferrari-main-car.png";
|
||||
heroPreview.style.backgroundImage = `url('${url}')`;
|
||||
}
|
||||
|
||||
heroImageInput.addEventListener("change", async () => {
|
||||
const file = heroImageInput.files?.[0];
|
||||
if (!file) return;
|
||||
heroFeedback.className = "form-feedback";
|
||||
heroFeedback.textContent = "Uploading...";
|
||||
try {
|
||||
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
||||
const path = `site/hero.${ext}`;
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from("vehicle-photos")
|
||||
.upload(path, file, { contentType: file.type, upsert: true });
|
||||
if (upErr) throw upErr;
|
||||
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
|
||||
const publicUrl = pub.publicUrl;
|
||||
|
||||
// Save to site_settings
|
||||
const { error: dbErr } = await supabase
|
||||
.from("site_settings")
|
||||
.upsert({ key: "hero_image_url", value: publicUrl, updated_at: new Date().toISOString() }, { onConflict: "key" });
|
||||
if (dbErr) throw dbErr;
|
||||
|
||||
heroPreview.style.backgroundImage = `url('${publicUrl}')`;
|
||||
heroFeedback.className = "form-feedback";
|
||||
heroFeedback.textContent = "Gespeichert.";
|
||||
} catch (err) {
|
||||
heroFeedback.className = "form-feedback error";
|
||||
heroFeedback.textContent = err.message || String(err);
|
||||
}
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -522,3 +522,11 @@ langToggle.textContent = getLang() === "de" ? "EN" : "DE";
|
||||
applyI18n();
|
||||
renderReviews();
|
||||
loadVehicles();
|
||||
|
||||
// Load hero image from site_settings
|
||||
(async () => {
|
||||
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
|
||||
if (data && data.value) {
|
||||
document.querySelector(".hero").style.setProperty("--hero-bg", `url('${data.value}')`);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -130,6 +130,9 @@ export const translations = {
|
||||
adminLeads: "Leads",
|
||||
adminCustomers: "Kunden",
|
||||
adminVehicles: "Fahrzeuge",
|
||||
adminSettings: "Einstellungen",
|
||||
adminHeroImage: "Hauptbild (Hero-Bereich)",
|
||||
adminHeroImageHint: "JPG/PNG/WebP. Wird als Hintergrundbild im Hero-Bereich der Website angezeigt.",
|
||||
adminNewVehicle: "Neues Fahrzeug",
|
||||
adminAllVehicles: "Alle Fahrzeuge",
|
||||
adminPhotoUpload: "Foto hochladen (JPG/PNG/WebP, max 50 MB)",
|
||||
@@ -358,6 +361,9 @@ export const translations = {
|
||||
adminLeads: "Leads",
|
||||
adminCustomers: "Customers",
|
||||
adminVehicles: "Vehicles",
|
||||
adminSettings: "Settings",
|
||||
adminHeroImage: "Main Photo (Hero Section)",
|
||||
adminHeroImageHint: "JPG/PNG/WebP. Displayed as the background image in the hero section of the website.",
|
||||
adminNewVehicle: "New vehicle",
|
||||
adminAllVehicles: "All vehicles",
|
||||
adminPhotoUpload: "Upload photo (JPG/PNG/WebP, max 50 MB)",
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ section { padding: 5rem 0; }
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(11,12,16,0.6) 0%, rgba(11,12,16,0.95) 100%),
|
||||
url('images/ferrari-main-car.png') center / cover no-repeat;
|
||||
var(--hero-bg, url('images/ferrari-main-car.png')) center / cover no-repeat;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 09-site-settings.sql
|
||||
-- Site-wide settings (key-value). Used for configurable assets like hero image.
|
||||
|
||||
create table if not exists public.site_settings (
|
||||
key text primary key,
|
||||
value text not null default '',
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.site_settings enable row level security;
|
||||
|
||||
-- Anyone can read settings (public site needs hero image URL)
|
||||
drop policy if exists "settings_public_read" on public.site_settings;
|
||||
create policy "settings_public_read"
|
||||
on public.site_settings for select
|
||||
using (true);
|
||||
|
||||
-- Only authenticated admins can modify
|
||||
drop policy if exists "settings_admin_all" on public.site_settings;
|
||||
create policy "settings_admin_all"
|
||||
on public.site_settings for all to authenticated
|
||||
using (true) with check (true);
|
||||
|
||||
grant select on public.site_settings to anon, authenticated, service_role;
|
||||
grant all on public.site_settings to authenticated, service_role;
|
||||
|
||||
-- Seed default hero image
|
||||
insert into public.site_settings (key, value)
|
||||
values ('hero_image_url', '/images/ferrari-main-car.png')
|
||||
on conflict (key) do nothing;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user