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.
This commit is contained in:
Lago
2026-04-17 17:50:57 +02:00
commit 61517879e1
23 changed files with 3673 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
_format_version: "2.1"
_transform: true
###
# MC Cars - Kong declarative config
# Routes traffic coming in on the Kong proxy port to each Supabase service.
###
consumers:
- username: anon
keyauth_credentials:
- key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
- username: service_role
keyauth_credentials:
- key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
- username: dashboard
basicauth_credentials:
- consumer: dashboard
username: supabase
password: mc-cars-studio
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
services:
########################################
# Auth (GoTrue)
########################################
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
########################################
# REST (PostgREST)
########################################
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
########################################
# Realtime (WebSocket subscriptions)
########################################
- name: realtime-v1
url: http://realtime:4000/socket
protocol: http
routes:
- name: realtime-v1-all
strip_path: true
paths:
- /realtime/v1/
protocols:
- http
- https
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
########################################
# Storage
########################################
- name: storage-v1
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
########################################
# postgres-meta (needed by Studio)
########################################
- name: meta
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
set -eu
psql -v ON_ERROR_STOP=1 -v pg_password="$POSTGRES_PASSWORD" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f /sql/01-init.sql
+198
View File
@@ -0,0 +1,198 @@
-- =============================================================================
-- MC Cars — Postgres bootstrap.
-- Creates the Supabase service roles that GoTrue / PostgREST / Storage expect,
-- installs required extensions, and sets up app schema + RLS + storage policies
-- + admin seed + sample fleet.
-- Runs once on first `docker compose up`.
-- =============================================================================
-- The Postgres connection is authenticated as role `supabase_admin` (POSTGRES_USER),
-- so we need it to be a superuser for the rest of this script to work. That is
-- handled by POSTGRES_USER=supabase_admin in compose.
create extension if not exists pgcrypto;
create extension if not exists "uuid-ossp";
-- Make the password from the shell wrapper available to the DO block below.
select set_config('mccars.pg_password', :'pg_password', false);
-- -----------------------------------------------------------------------------
-- Supabase service roles
-- -----------------------------------------------------------------------------
do $roles$
declare
pw text := current_setting('mccars.pg_password', true);
begin
if pw is null or pw = '' then
raise exception 'mccars.pg_password is not set';
end if;
-- anon (used by PostgREST for unauthenticated requests)
if not exists (select 1 from pg_roles where rolname = 'anon') then
execute 'create role anon nologin noinherit';
end if;
-- authenticated (role the JWT "authenticated" maps to)
if not exists (select 1 from pg_roles where rolname = 'authenticated') then
execute 'create role authenticated nologin noinherit';
end if;
-- service_role (full access; never exposed to the browser)
if not exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'create role service_role nologin noinherit bypassrls';
end if;
-- authenticator (PostgREST logs in as this and switches role per JWT)
if not exists (select 1 from pg_roles where rolname = 'authenticator') then
execute format('create role authenticator login noinherit password %L', pw);
else
execute format('alter role authenticator with login noinherit password %L', pw);
end if;
-- supabase_auth_admin (GoTrue logs in with this, needs schema auth)
if not exists (select 1 from pg_roles where rolname = 'supabase_auth_admin') then
execute format('create role supabase_auth_admin login createrole password %L', pw);
else
execute format('alter role supabase_auth_admin with login createrole password %L', pw);
end if;
-- supabase_storage_admin (Storage service logs in with this)
if not exists (select 1 from pg_roles where rolname = 'supabase_storage_admin') then
execute format('create role supabase_storage_admin login createrole password %L', pw);
else
execute format('alter role supabase_storage_admin with login createrole password %L', pw);
end if;
-- Let authenticator impersonate app roles.
execute 'grant anon, authenticated, service_role to authenticator';
end
$roles$;
-- -----------------------------------------------------------------------------
-- Schemas for GoTrue / Storage (they create their own objects, but own schema)
-- -----------------------------------------------------------------------------
create schema if not exists auth authorization supabase_auth_admin;
create schema if not exists storage authorization supabase_storage_admin;
create schema if not exists _realtime authorization postgres;
grant usage on schema auth to service_role, authenticated, anon;
grant usage on schema storage to service_role, authenticated, anon;
grant all on schema _realtime to postgres;
-- Allow service admins to create/alter objects for their own migrations.
grant create, connect on database postgres to supabase_auth_admin, supabase_storage_admin;
grant all on schema auth to supabase_auth_admin;
grant all on schema storage to supabase_storage_admin;
-- Storage-api's migration process expects to see public schema too.
grant usage, create on schema public to supabase_storage_admin, supabase_auth_admin;
-- -----------------------------------------------------------------------------
-- Application schema: public.vehicles
-- -----------------------------------------------------------------------------
create table if not exists public.vehicles (
id uuid primary key default gen_random_uuid(),
brand text not null,
model text not null,
power_hp integer not null default 0,
top_speed_kmh integer not null default 0,
acceleration text not null default '',
seats integer not null default 2,
daily_price_eur integer not null default 0,
location text not null default 'Steiermark (TBD)',
description_de text not null default '',
description_en text not null default '',
photo_url text not null default '',
photo_path text,
sort_order integer not null default 100,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists vehicles_active_sort_idx
on public.vehicles (is_active, sort_order);
create or replace function public.tg_touch_updated_at() returns trigger
language plpgsql as $$
begin
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists vehicles_touch on public.vehicles;
create trigger vehicles_touch
before update on public.vehicles
for each row execute function public.tg_touch_updated_at();
alter table public.vehicles enable row level security;
drop policy if exists "vehicles_public_read" on public.vehicles;
drop policy if exists "vehicles_admin_read_all" on public.vehicles;
drop policy if exists "vehicles_admin_insert" on public.vehicles;
drop policy if exists "vehicles_admin_update" on public.vehicles;
drop policy if exists "vehicles_admin_delete" on public.vehicles;
create policy "vehicles_public_read"
on public.vehicles for select
using (is_active = true);
create policy "vehicles_admin_read_all"
on public.vehicles for select
to authenticated using (true);
create policy "vehicles_admin_insert"
on public.vehicles for insert
to authenticated with check (true);
create policy "vehicles_admin_update"
on public.vehicles for update
to authenticated using (true) with check (true);
create policy "vehicles_admin_delete"
on public.vehicles for delete
to authenticated using (true);
grant select on public.vehicles to anon, authenticated;
grant insert, update, delete on public.vehicles to authenticated;
grant all on public.vehicles to service_role;
-- -----------------------------------------------------------------------------
-- Seed sample fleet (admin can replace any row / photo via the UI)
-- -----------------------------------------------------------------------------
insert into public.vehicles
(brand, model, power_hp, top_speed_kmh, acceleration, seats,
daily_price_eur, location, description_de, description_en, photo_url, sort_order)
values
('Porsche','911 GT3', 510, 318, '3.4s', 2, 890, 'Steiermark (TBD)',
'Puristischer Hochdrehzahl-Saugmotor und kompromissloser Motorsport-Charakter.',
'Pure high-revving naturally aspirated engine with uncompromising motorsport character.',
'https://images.unsplash.com/photo-1611821064430-0d40291d0f0b?auto=format&fit=crop&w=1400&q=80',
10),
('Lamborghini','Huracan EVO', 640, 325, '2.9s', 2, 990, 'Steiermark (TBD)',
'V10 mit 640 PS, scharfes Design und kompromisslose Performance auf Strasse und Rennstrecke.',
'V10 with 640 hp, sharp design and uncompromising performance on road and track.',
'https://images.unsplash.com/photo-1544636331-e26879cd4d9b?auto=format&fit=crop&w=1400&q=80',
20),
('Audi','RS6 Performance', 630, 305, '3.4s', 5, 540, 'Steiermark (TBD)',
'Alltagstauglicher Kombi mit brutaler V8-Biturbo-Power und Allradantrieb.',
'Everyday-ready estate with brutal twin-turbo V8 power and quattro AWD.',
'https://images.unsplash.com/photo-1606664515524-ed2f786a0bd6?auto=format&fit=crop&w=1400&q=80',
30),
('BMW','M4 Competition', 530, 290, '3.5s', 4, 430, 'Steiermark (TBD)',
'Reihensechszylinder-Biturbo mit praeziser Lenkung und sportlichem Fahrwerk.',
'Twin-turbo inline-six with precise steering and sporty chassis.',
'https://images.unsplash.com/photo-1555215695-3004980ad54e?auto=format&fit=crop&w=1400&q=80',
40),
('Nissan','GT-R R35', 570, 315, '2.8s', 4, 510, 'Steiermark (TBD)',
'Ikonischer Allrad-Supersportler mit Twin-Turbo V6 und brutalem Antritt.',
'Iconic AWD supercar with twin-turbo V6 and ferocious launch.',
'https://images.unsplash.com/photo-1626668893632-6f3a4466d22f?auto=format&fit=crop&w=1400&q=80',
50),
('Mercedes-AMG','G63', 585, 220, '4.5s', 5, 620, 'Steiermark (TBD)',
'Legendaere G-Klasse mit V8-Biturbo-Performance und unverkennbarem Design.',
'Legendary G-Class with V8 biturbo performance and unmistakable design.',
'https://images.unsplash.com/photo-1606611013016-969c19ba27bb?auto=format&fit=crop&w=1400&q=80',
60)
on conflict do nothing;
+252
View File
@@ -0,0 +1,252 @@
-- =============================================================================
-- MC Cars - CRM schema: leads + customers + realtime publication.
-- Runs from post-init AFTER auth/storage schemas exist.
-- Idempotent.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- LEADS: landing-page form submissions (anon may INSERT, only authenticated
-- may SELECT/UPDATE).
-- -----------------------------------------------------------------------------
create table if not exists public.leads (
id uuid primary key default gen_random_uuid(),
name text not null,
email text not null,
phone text not null default '',
vehicle_id uuid references public.vehicles(id) on delete set null,
vehicle_label text not null default '', -- denormalized (brand + model) at submit time
date_from date,
date_to date,
message text not null default '',
status text not null default 'new'
check (status in ('new','qualified','disqualified')),
is_active boolean not null default true, -- false once qualified/disqualified
admin_notes text not null default '',
source text not null default 'website',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
qualified_at timestamptz,
qualified_by uuid references auth.users(id) on delete set null
);
create index if not exists leads_active_created_idx on public.leads (is_active, created_at desc);
create index if not exists leads_status_idx on public.leads (status);
create index if not exists leads_vehicle_idx on public.leads (vehicle_id);
drop trigger if exists leads_touch on public.leads;
create trigger leads_touch
before update on public.leads
for each row execute function public.tg_touch_updated_at();
alter table public.leads enable row level security;
drop policy if exists "leads_anon_insert" on public.leads;
drop policy if exists "leads_admin_select_all" on public.leads;
drop policy if exists "leads_admin_update" on public.leads;
drop policy if exists "leads_admin_delete" on public.leads;
-- Anonymous visitors may submit the booking form, but NOT read anything back.
create policy "leads_anon_insert"
on public.leads for insert to anon
with check (true);
create policy "leads_admin_select_all"
on public.leads for select to authenticated
using (true);
create policy "leads_admin_update"
on public.leads for update to authenticated
using (true) with check (true);
create policy "leads_admin_delete"
on public.leads for delete to authenticated
using (true);
grant insert on public.leads to anon;
grant select, insert, update, delete on public.leads to authenticated;
grant all on public.leads to service_role;
-- -----------------------------------------------------------------------------
-- CUSTOMERS: created ONLY by qualifying a lead from the admin panel.
-- Keeps a hard FK back to the originating lead for audit.
-- -----------------------------------------------------------------------------
create table if not exists public.customers (
id uuid primary key default gen_random_uuid(),
lead_id uuid not null references public.leads(id) on delete restrict,
name text not null,
email text not null,
phone text not null default '',
first_contacted_at timestamptz not null default now(),
notes text not null default '',
status text not null default 'active'
check (status in ('active','inactive')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
created_by uuid references auth.users(id) on delete set null
);
create unique index if not exists customers_lead_unique on public.customers (lead_id);
create index if not exists customers_email_idx on public.customers (email);
drop trigger if exists customers_touch on public.customers;
create trigger customers_touch
before update on public.customers
for each row execute function public.tg_touch_updated_at();
alter table public.customers enable row level security;
drop policy if exists "customers_admin_select" on public.customers;
drop policy if exists "customers_admin_insert" on public.customers;
drop policy if exists "customers_admin_update" on public.customers;
drop policy if exists "customers_admin_delete" on public.customers;
create policy "customers_admin_select"
on public.customers for select to authenticated using (true);
create policy "customers_admin_insert"
on public.customers for insert to authenticated with check (true);
create policy "customers_admin_update"
on public.customers for update to authenticated using (true) with check (true);
create policy "customers_admin_delete"
on public.customers for delete to authenticated using (true);
grant select, insert, update, delete on public.customers to authenticated;
grant all on public.customers to service_role;
-- -----------------------------------------------------------------------------
-- qualify_lead(lead_id, notes)
-- Transactional: marks the lead qualified+inactive and creates the matching
-- customer row. Called via PostgREST RPC by the admin UI.
-- -----------------------------------------------------------------------------
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
returns public.customers
language plpgsql
security invoker
as $$
declare
v_lead public.leads;
v_customer public.customers;
v_user uuid := auth.uid();
begin
select * into v_lead from public.leads where id = p_lead_id for update;
if not found then
raise exception 'lead % not found', p_lead_id;
end if;
if v_lead.status = 'qualified' then
select * into v_customer from public.customers where lead_id = v_lead.id;
return v_customer;
end if;
update public.leads
set status = 'qualified',
is_active = false,
qualified_at = now(),
qualified_by = v_user,
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
where id = v_lead.id;
insert into public.customers (lead_id, name, email, phone, notes, created_by)
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
returning * into v_customer;
return v_customer;
end;
$$;
revoke all on function public.qualify_lead(uuid, text) from public;
grant execute on function public.qualify_lead(uuid, text) to authenticated;
-- -----------------------------------------------------------------------------
-- disqualify_lead(lead_id, notes)
-- -----------------------------------------------------------------------------
create or replace function public.disqualify_lead(p_lead_id uuid, p_notes text default '')
returns public.leads
language plpgsql
security invoker
as $$
declare
v_lead public.leads;
begin
update public.leads
set status = 'disqualified',
is_active = false,
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
where id = p_lead_id
returning * into v_lead;
if not found then
raise exception 'lead % not found', p_lead_id;
end if;
return v_lead;
end;
$$;
revoke all on function public.disqualify_lead(uuid, text) from public;
grant execute on function public.disqualify_lead(uuid, text) to authenticated;
-- -----------------------------------------------------------------------------
-- reopen_lead(lead_id): moves a lead back to active/new (admin correction).
-- -----------------------------------------------------------------------------
create or replace function public.reopen_lead(p_lead_id uuid)
returns public.leads
language plpgsql
security invoker
as $$
declare
v_lead public.leads;
begin
update public.leads
set status = 'new',
is_active = true,
qualified_at = null,
qualified_by = null
where id = p_lead_id
returning * into v_lead;
-- If a customer was spawned from this lead, remove it.
delete from public.customers where lead_id = p_lead_id;
return v_lead;
end;
$$;
revoke all on function public.reopen_lead(uuid) from public;
grant execute on function public.reopen_lead(uuid) to authenticated;
-- -----------------------------------------------------------------------------
-- Realtime: publish leads + customers so the admin UI sees live inserts/updates.
-- -----------------------------------------------------------------------------
do $$
declare t text;
begin
if not exists (select 1 from pg_publication where pubname = 'supabase_realtime') then
create publication supabase_realtime;
end if;
foreach t in array array['leads','customers','vehicles'] loop
if not exists (
select 1 from pg_publication_tables
where pubname='supabase_realtime' and schemaname='public' and tablename=t
) then
execute format('alter publication supabase_realtime add table public.%I', t);
end if;
end loop;
end
$$;
alter table public.leads replica identity full;
alter table public.customers replica identity full;
alter table public.vehicles replica identity full;
-- Tell PostgREST to reload its schema cache after new tables/functions appear.
notify pgrst, 'reload schema';
+81
View File
@@ -0,0 +1,81 @@
-- Runs AFTER GoTrue and Storage auto-migrate their schemas.
-- Seeds the admin user (from psql vars :admin_email / :admin_password),
-- the vehicle-photos storage bucket, and storage RLS policies. Idempotent.
--
-- IMPORTANT: the seeded password is a BOOTSTRAP value only. The admin UI
-- enforces a password rotation on first login via
-- auth.users.raw_user_meta_data.must_change_password=true, so the real
-- operational password is NEVER equal to the .env seed.
-- Publish psql vars as GUCs so the DO block can read them reliably.
select set_config('mccars.admin_email', :'admin_email', false);
select set_config('mccars.admin_password', :'admin_password', false);
do $$
declare
v_user_id uuid;
v_email text := coalesce(nullif(current_setting('mccars.admin_email', true), ''), 'admin@mccars.local');
v_pass text := coalesce(nullif(current_setting('mccars.admin_password', true), ''), 'mc-cars-admin');
begin
if not exists (select 1 from auth.users where email = v_email) then
v_user_id := gen_random_uuid();
insert into auth.users (
id, instance_id, aud, role, email, encrypted_password,
email_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at, is_super_admin,
confirmation_token, email_change, email_change_token_new, recovery_token
) values (
v_user_id,
'00000000-0000-0000-0000-000000000000',
'authenticated', 'authenticated',
v_email,
crypt(v_pass, gen_salt('bf')),
now(),
jsonb_build_object('provider','email','providers',jsonb_build_array('email')),
jsonb_build_object('must_change_password', true),
now(), now(), false, '', '', '', ''
);
insert into auth.identities (
id, user_id, identity_data, provider, provider_id,
last_sign_in_at, created_at, updated_at
) values (
gen_random_uuid(), v_user_id,
jsonb_build_object('sub', v_user_id::text, 'email', v_email),
'email', v_email,
now(), now(), now()
);
end if;
end
$$;
-- -----------------------------------------------------------------------------
-- Storage bucket + RLS
-- -----------------------------------------------------------------------------
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('vehicle-photos','vehicle-photos', true, 52428800,
array['image/jpeg','image/png','image/webp','image/avif'])
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
drop policy if exists "vehicle_photos_public_read" on storage.objects;
drop policy if exists "vehicle_photos_admin_insert" on storage.objects;
drop policy if exists "vehicle_photos_admin_update" on storage.objects;
drop policy if exists "vehicle_photos_admin_delete" on storage.objects;
create policy "vehicle_photos_public_read"
on storage.objects for select using (bucket_id = 'vehicle-photos');
create policy "vehicle_photos_admin_insert"
on storage.objects for insert to authenticated
with check (bucket_id = 'vehicle-photos');
create policy "vehicle_photos_admin_update"
on storage.objects for update to authenticated
using (bucket_id = 'vehicle-photos') with check (bucket_id = 'vehicle-photos');
create policy "vehicle_photos_admin_delete"
on storage.objects for delete to authenticated
using (bucket_id = 'vehicle-photos');