This page explains all entities, their relationships and why they exist in the Soulfire workflow:
SEO article → registration + free PDF → tests / gamification → paid materials → mentoring.
Architecture update: Lovable is the main SPA front end. News and frequently edited pages are generated with a static-site generator. WordPress has been removed.
1. Identity and profiles
Entity
Purpose
Main columns
Notes
auth.users(built-in Supabase)
Core identity, sign-in, 2FA, refresh tokens.
id, email, hashed_password, last_sign_in_at …
Left untouched, used as provided.
user_profiles
Extended personal data used across the SPA and static site.
The database is created with the SQL below (for reference only).
CREATE TABLE public.coaching_bookings (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
service_id uuid NOT NULL,
booking_time timestamp with time zone NOT NULL,
status USER-DEFINED DEFAULT 'requested'::booking_status,
price_cents integer,
notes text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT coaching_bookings_pkey PRIMARY KEY (id),
CONSTRAINT coaching_bookings_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT coaching_bookings_service_id_fkey FOREIGN KEY (service_id) REFERENCES public.coaching_services(id)
);
CREATE TABLE public.coaching_services (
id uuid NOT NULL DEFAULT gen_random_uuid(),
slug text UNIQUE,
title text NOT NULL,
description text,
price_cents integer,
duration_minutes integer,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT coaching_services_pkey PRIMARY KEY (id)
);
CREATE TABLE public.coupon_codes (
id uuid NOT NULL DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE,
product_id uuid,
percent_off smallint CHECK (percent_off >= 1 AND percent_off <= 100),
expires_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT coupon_codes_pkey PRIMARY KEY (id),
CONSTRAINT coupon_codes_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id)
);
CREATE TABLE public.downloads (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
material_id uuid NOT NULL,
ts timestamp with time zone DEFAULT now(),
CONSTRAINT downloads_pkey PRIMARY KEY (id),
CONSTRAINT downloads_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT downloads_material_id_fkey FOREIGN KEY (material_id) REFERENCES public.materials(id)
);
CREATE TABLE public.email_events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid,
email_type text,
meta jsonb,
sent_at timestamp with time zone DEFAULT now(),
CONSTRAINT email_events_pkey PRIMARY KEY (id),
CONSTRAINT email_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id)
);
CREATE TABLE public.materials (
id uuid NOT NULL DEFAULT gen_random_uuid(),
product_id uuid NOT NULL,
path text NOT NULL,
title text,
is_free boolean DEFAULT false,
created_at timestamp with time zone DEFAULT now(),
locale text NOT NULL DEFAULT 'cs'::text,
CONSTRAINT materials_pkey PRIMARY KEY (id),
CONSTRAINT materials_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id)
);
CREATE TABLE public.orders (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
product_id uuid NOT NULL,
stripe_session_id text UNIQUE,
status USER-DEFINED DEFAULT 'pending'::order_status,
total_amount_cents integer,
currency character DEFAULT 'CZK'::bpchar,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT orders_pkey PRIMARY KEY (id),
CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT orders_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id)
);
CREATE TABLE public.products (
id uuid NOT NULL DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text,
stripe_price_id text UNIQUE,
is_free boolean DEFAULT false,
created_at timestamp with time zone DEFAULT now(),
locale text NOT NULL DEFAULT 'cs'::text,
CONSTRAINT products_pkey PRIMARY KEY (id)
);
CREATE TABLE public.site_sections (
slug text NOT NULL,
label text NOT NULL,
parent text,
is_enabled boolean NOT NULL DEFAULT true,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
display_order integer NOT NULL DEFAULT 999,
label_en text,
label_cs text,
CONSTRAINT site_sections_pkey PRIMARY KEY (slug),
CONSTRAINT site_sections_parent_fkey FOREIGN KEY (parent) REFERENCES public.site_sections(slug)
);
CREATE TABLE public.posts (
id uuid NOT NULL DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
title text NOT NULL,
body text,
excerpt text,
published_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT posts_pkey PRIMARY KEY (id)
);
CREATE TABLE public.test_questions (
id uuid NOT NULL DEFAULT gen_random_uuid(),
test_id uuid NOT NULL,
q_order integer,
question text NOT NULL,
scale text,
locale text NOT NULL DEFAULT 'cs'::text,
CONSTRAINT test_questions_pkey PRIMARY KEY (id),
CONSTRAINT test_questions_test_id_fkey FOREIGN KEY (test_id) REFERENCES public.tests(id)
);
CREATE TABLE public.test_result_configs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
test_slug text NOT NULL,
result_key text NOT NULL,
title text NOT NULL,
description text,
min_score numeric NOT NULL,
max_score numeric NOT NULL,
display_order smallint DEFAULT 999,
icon_slug text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT test_result_configs_pkey PRIMARY KEY (id)
);
CREATE TABLE public.test_results (
test_type text,
tier_result text,
personality_results jsonb,
status text DEFAULT 'completed'::text CHECK (status = ANY (ARRAY['in_progress'::text, 'completed'::text])),
meta jsonb DEFAULT '{}'::jsonb,
user_id uuid NOT NULL,
answers jsonb,
scores jsonb,
total_score numeric,
id uuid NOT NULL DEFAULT gen_random_uuid(),
completed_at timestamp with time zone DEFAULT now(),
test_id uuid,
CONSTRAINT test_results_pkey PRIMARY KEY (id),
CONSTRAINT test_results_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT test_results_test_id_fkey FOREIGN KEY (test_id) REFERENCES public.tests(id)
);
CREATE TABLE public.tests (
id uuid NOT NULL DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text,
CONSTRAINT tests_pkey PRIMARY KEY (id)
);
CREATE TABLE public.user_profiles (
divorce_risk_level text CHECK (divorce_risk_level = ANY (ARRAY['low'::text, 'moderate'::text, 'high'::text])),
id uuid NOT NULL,
first_name text,
last_name text,
birth_date date,
relationship_status text,
avatar_url text,
tier_result text,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
couple_name text UNIQUE,
male_first_name text,
male_last_name text,
male_birth_date date,
female_first_name text,
female_last_name text,
female_birth_date date,
male_avatar_url text,
female_avatar_url text,
couple_avatar_url text,
locale text NOT NULL DEFAULT 'cs'::text,
CONSTRAINT user_profiles_pkey PRIMARY KEY (id),
CONSTRAINT user_profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id)
);
2. Content catalog
Entity
Purpose
Main columns
Relations
posts
News and blog articles generated into static pages.
Individual files or lessons belonging to a product.
id, product_id, path, title, is_free, created_at
RLS: row is visible if is_free = true or the user owns a row in orders with status='paid'.
Why the separation? products enable pricing in Stripe while materials provide flexibility: a single course can contain multiple PDFs, videos and tests.
3. Orders and access
Entity
Purpose
Main columns
Relations / logic
orders
Record of every purchase (Stripe Checkout via webhook).
id, user_id, product_id, stripe_session_id, status (pending/paid/…), total_amount_cents, created_at
Webhook sets status='paid' which unlocks materials.
After login the SPA sets a sb-access-token cookie shared for subdomains so the static site also knows the user is signed in.
Product catalog is loaded with a public SELECT from products.
The user clicks a free PDF: the frontend checks material.is_free = true, signs the URL (60 s) and logs a row in downloads.
After a campaign they purchase a guide → Stripe Checkout → webhook inserts/updates a row in orders with status='paid'.
When they return to the product page, RLS now allows paid materials and the download works the same way.
Tests (TIER, BIG‑5 …) use a generic structure, so adding a new test is just seeding tests and its questions. Results are stored in test_results; the profile can be enriched with tier_result.
Mentoring bookings – the user selects a service, creates a row in coaching_bookings (status='requested'). Back‑office confirms and sends notifications via Resend (logged in email_events).
Why this structure is useful
Minimal duplication – progress is derived from existing tables and one RLS condition governs paid content.
Easy to extend – add a new test, guide or service via seed without further migrations.
Security – no API gateway required: the front‑end SELECTs directly from Postgres while RLS protects rows.
Static site compatibility – the cookie keeps users signed in across the SPA and SSG pages.