RLS policies implementeren, Demo auth flow

This commit is contained in:
colinislit
2025-11-15 23:59:38 +01:00
parent b1cfb339a2
commit 99804656d7
49 changed files with 5841 additions and 123 deletions

View File

@@ -0,0 +1,69 @@
-- Create leads table for contact form submissions
-- Migration: 20241115000001_create_leads_table
-- Description: Stores lead information from contact form with project details
create table if not exists public.leads (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
-- Contact information
name text not null,
email text not null,
company text,
-- Project details
project_type text not null,
budget text,
message text not null,
-- Metadata
status text not null default 'new' check (status in ('new', 'contacted', 'qualified', 'converted', 'rejected')),
notes text,
source text default 'website',
-- Tracking
ip_address inet,
user_agent text,
referrer text
);
-- Add index for email lookups
create index if not exists leads_email_idx on public.leads (email);
-- Add index for status filtering
create index if not exists leads_status_idx on public.leads (status);
-- Add index for created_at sorting
create index if not exists leads_created_at_idx on public.leads (created_at desc);
-- Enable Row Level Security
alter table public.leads enable row level security;
-- Policy: Anyone can insert (public form submission)
create policy "Anyone can submit leads"
on public.leads
for insert
to anon, authenticated
with check (true);
-- Policy: Only authenticated users can view leads (admin only)
create policy "Authenticated users can view leads"
on public.leads
for select
to authenticated
using (true);
-- Policy: Only authenticated users can update leads (admin only)
create policy "Authenticated users can update leads"
on public.leads
for update
to authenticated
using (true);
-- Add comment to table
comment on table public.leads is 'Contact form lead submissions with project details';
-- Add comments to important columns
comment on column public.leads.status is 'Lead status: new, contacted, qualified, converted, rejected';
comment on column public.leads.project_type is 'Type of project: Web app, Mobile app, etc.';
comment on column public.leads.message is 'Project description from contact form';

View File

@@ -0,0 +1,230 @@
-- ================================================
-- EPD Core Tables Migration
-- Created: 2024-11-15
-- Epic: E2 - Database & Auth
-- Story: E2.S1 - Database schema creëren
-- ================================================
-- This migration creates the 5 core EPD tables:
-- 1. clients - basic client information
-- 2. intake_notes - TipTap JSON content + derived fields
-- 3. problem_profiles - DSM-light categories + severity
-- 4. treatment_plans - Treatment plan JSONB with versioning
-- 5. ai_events - AI API telemetry and debugging
-- ================================================
-- ================================================
-- TABLE 1: clients
-- ================================================
-- Stores basic client information
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
birth_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Constraints
CONSTRAINT clients_name_not_empty CHECK (
LENGTH(TRIM(first_name)) > 0 AND LENGTH(TRIM(last_name)) > 0
)
);
-- Index for searching clients by name
CREATE INDEX idx_clients_name ON clients(last_name, first_name);
-- ================================================
-- TABLE 2: intake_notes
-- ================================================
-- Stores intake notes with TipTap/ProseMirror JSON content
CREATE TABLE intake_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
title TEXT,
tag TEXT CHECK (tag IN ('Intake', 'Evaluatie', 'Plan')),
content_json JSONB NOT NULL DEFAULT '{}'::jsonb,
content_text TEXT, -- Derived text for full-text search
author UUID, -- FK to auth.users.id (optional for MVP)
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Constraints
CONSTRAINT intake_notes_content_not_empty CHECK (
content_json IS NOT NULL
)
);
-- Indexes for performance
CREATE INDEX idx_intake_notes_client ON intake_notes(client_id);
CREATE INDEX idx_intake_notes_created ON intake_notes(created_at DESC);
-- Full-text search index on content_text (for future search functionality)
CREATE INDEX idx_intake_notes_fts ON intake_notes USING gin(to_tsvector('dutch', content_text));
-- ================================================
-- TABLE 3: problem_profiles
-- ================================================
-- Stores DSM-light problem categorization
CREATE TABLE problem_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
category TEXT NOT NULL CHECK (category IN (
'stemming_depressie',
'angst',
'gedrag_impuls',
'middelen_gebruik',
'cognitief',
'context_psychosociaal'
)),
severity TEXT NOT NULL CHECK (severity IN ('laag', 'middel', 'hoog')),
remarks TEXT,
source_note_id UUID REFERENCES intake_notes(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Indexes
CREATE INDEX idx_problem_profiles_client ON problem_profiles(client_id);
CREATE INDEX idx_problem_profiles_category ON problem_profiles(category);
-- ================================================
-- TABLE 4: treatment_plans
-- ================================================
-- Stores treatment plans with versioning
CREATE TABLE treatment_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'concept' CHECK (status IN ('concept', 'gepubliceerd')),
plan JSONB NOT NULL DEFAULT '{
"doelen": [],
"interventies": [],
"frequentie": "",
"meetmomenten": []
}'::jsonb,
created_by UUID, -- FK to auth.users.id (optional for MVP)
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
published_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Constraints
CONSTRAINT treatment_plans_version_positive CHECK (version > 0),
CONSTRAINT treatment_plans_plan_structure CHECK (
plan ? 'doelen' AND
plan ? 'interventies' AND
plan ? 'frequentie' AND
plan ? 'meetmomenten'
),
-- Ensure published plans have published_at timestamp
CONSTRAINT treatment_plans_published_timestamp CHECK (
(status = 'gepubliceerd' AND published_at IS NOT NULL) OR
(status = 'concept')
),
-- Unique version per client
UNIQUE(client_id, version)
);
-- Indexes
CREATE INDEX idx_treatment_plans_client ON treatment_plans(client_id);
CREATE INDEX idx_treatment_plans_status ON treatment_plans(status);
CREATE INDEX idx_treatment_plans_version ON treatment_plans(client_id, version DESC);
-- ================================================
-- TABLE 5: ai_events
-- ================================================
-- Telemetry and debugging for AI API calls
CREATE TABLE ai_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL CHECK (kind IN ('summarize', 'readability', 'extract', 'plan')),
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
note_id UUID REFERENCES intake_notes(id) ON DELETE SET NULL,
request JSONB NOT NULL DEFAULT '{}'::jsonb,
response JSONB NOT NULL DEFAULT '{}'::jsonb,
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Constraints
CONSTRAINT ai_events_duration_non_negative CHECK (duration_ms >= 0)
);
-- Indexes for analytics and debugging
CREATE INDEX idx_ai_events_kind ON ai_events(kind);
CREATE INDEX idx_ai_events_client ON ai_events(client_id);
CREATE INDEX idx_ai_events_created ON ai_events(created_at DESC);
-- ================================================
-- ROW LEVEL SECURITY (RLS) POLICIES
-- ================================================
-- Enable RLS on all tables
ALTER TABLE clients ENABLE ROW LEVEL SECURITY;
ALTER TABLE intake_notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE problem_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE treatment_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE ai_events ENABLE ROW LEVEL SECURITY;
-- Demo RLS policies: All authenticated users can access all data
-- (For MVP/demo purposes only - in production, use org_id or user_id filtering)
CREATE POLICY "Allow all for authenticated users" ON clients
FOR ALL USING (auth.uid() IS NOT NULL);
CREATE POLICY "Allow all for authenticated users" ON intake_notes
FOR ALL USING (auth.uid() IS NOT NULL);
CREATE POLICY "Allow all for authenticated users" ON problem_profiles
FOR ALL USING (auth.uid() IS NOT NULL);
CREATE POLICY "Allow all for authenticated users" ON treatment_plans
FOR ALL USING (auth.uid() IS NOT NULL);
CREATE POLICY "Allow all for authenticated users" ON ai_events
FOR ALL USING (auth.uid() IS NOT NULL);
-- ================================================
-- TRIGGER FUNCTIONS FOR updated_at
-- ================================================
-- Automatically update updated_at timestamp on row updates
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply trigger to all tables with updated_at column
CREATE TRIGGER update_clients_updated_at
BEFORE UPDATE ON clients
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_intake_notes_updated_at
BEFORE UPDATE ON intake_notes
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_problem_profiles_updated_at
BEFORE UPDATE ON problem_profiles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_treatment_plans_updated_at
BEFORE UPDATE ON treatment_plans
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ================================================
-- COMMENTS (PostgreSQL Documentation)
-- ================================================
COMMENT ON TABLE clients IS 'Basic client information for EPD system';
COMMENT ON TABLE intake_notes IS 'Intake notes stored as TipTap/ProseMirror JSON with derived text field for search';
COMMENT ON TABLE problem_profiles IS 'DSM-light problem categorization with severity scoring';
COMMENT ON TABLE treatment_plans IS 'Treatment plans with JSONB structure and versioning support';
COMMENT ON TABLE ai_events IS 'Telemetry and debugging log for AI API calls';
COMMENT ON COLUMN intake_notes.content_json IS 'ProseMirror/TipTap document structure (JSONB)';
COMMENT ON COLUMN intake_notes.content_text IS 'Plain text extraction for full-text search indexing';
COMMENT ON COLUMN treatment_plans.version IS 'Incremental version number, unique per client';
COMMENT ON COLUMN treatment_plans.status IS 'Draft status: concept (editable) or gepubliceerd (locked)';
COMMENT ON COLUMN ai_events.duration_ms IS 'API call duration in milliseconds for performance monitoring';

View File

@@ -0,0 +1,180 @@
-- ================================================
-- RLS Policy Tests
-- Created: 2024-11-15
-- Epic: E2 - Database & Auth
-- Story: E2.S2 - RLS policies implementeren
-- ================================================
-- This file contains test queries to verify RLS policies
-- Run these queries manually to verify RLS is working correctly
-- ================================================
-- ================================================
-- TEST 1: Verify RLS is enabled on all tables
-- ================================================
-- Expected: All tables should have rowsecurity = true
SELECT
tablename,
rowsecurity as rls_enabled
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
-- Expected output:
-- ai_events | true
-- clients | true
-- intake_notes | true
-- problem_profiles | true
-- treatment_plans | true
-- ================================================
-- TEST 2: Check all RLS policies exist
-- ================================================
-- Expected: Each table should have 4 policies (SELECT, INSERT, UPDATE, DELETE)
-- except ai_events which has only 2 (SELECT, INSERT)
SELECT
tablename,
COUNT(*) as policy_count,
STRING_AGG(cmd, ', ' ORDER BY cmd) as commands
FROM pg_policies
WHERE schemaname = 'public'
GROUP BY tablename
ORDER BY tablename;
-- Expected output:
-- ai_events | 2 | INSERT, SELECT
-- clients | 4 | DELETE, INSERT, SELECT, UPDATE
-- intake_notes | 4 | DELETE, INSERT, SELECT, UPDATE
-- problem_profiles | 4 | DELETE, INSERT, SELECT, UPDATE
-- treatment_plans | 4 | DELETE, INSERT, SELECT, UPDATE
-- ================================================
-- TEST 3: Verify policy predicates use auth.uid()
-- ================================================
-- Expected: All policies should check auth.uid() IS NOT NULL
SELECT
tablename,
policyname,
cmd,
qual as using_clause,
with_check
FROM pg_policies
WHERE schemaname = 'public'
AND qual NOT LIKE '%auth.uid()%'
ORDER BY tablename, policyname;
-- Expected output: Empty (no policies without auth.uid() check)
-- ================================================
-- TEST 4: Simulate authenticated user query
-- ================================================
-- This test simulates what happens when an authenticated user
-- tries to access data. In production, auth.uid() would return
-- the user's actual UUID.
-- Note: These queries will work in the SQL editor when logged in,
-- but will fail when run as unauthenticated
-- Test SELECT permission (should succeed when authenticated)
-- SELECT * FROM clients LIMIT 1;
-- Test INSERT permission (should succeed when authenticated)
-- INSERT INTO clients (first_name, last_name, birth_date)
-- VALUES ('Test', 'User', '1990-01-01');
-- Test UPDATE permission (should succeed when authenticated)
-- UPDATE clients SET first_name = 'Updated' WHERE id = 'some-uuid';
-- Test DELETE permission (should succeed when authenticated)
-- DELETE FROM clients WHERE id = 'some-uuid';
-- ================================================
-- TEST 5: Verify ai_events immutability
-- ================================================
-- Expected: ai_events should NOT have UPDATE or DELETE policies
-- (except for service role via RLS bypass)
SELECT
tablename,
policyname,
cmd
FROM pg_policies
WHERE schemaname = 'public'
AND tablename = 'ai_events'
AND cmd IN ('UPDATE', 'DELETE')
ORDER BY cmd;
-- Expected output: Empty (no UPDATE or DELETE policies for regular users)
-- ================================================
-- TEST 6: Check foreign key relationships
-- ================================================
-- Expected: All foreign keys should be properly set up
SELECT
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name,
rc.delete_rule
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
ORDER BY tc.table_name, kcu.column_name;
-- Expected output:
-- intake_notes | client_id | clients | id | CASCADE
-- problem_profiles | client_id | clients | id | CASCADE
-- problem_profiles | source_note_id | intake_notes | id | SET NULL
-- treatment_plans | client_id | clients | id | CASCADE
-- ai_events | client_id | clients | id | SET NULL
-- ai_events | note_id | intake_notes | id | SET NULL
-- ================================================
-- PRODUCTION MIGRATION PATH
-- ================================================
-- When moving to production, enhance policies with org_id filtering:
--
-- 1. Add org_id column to all tables:
-- ALTER TABLE clients ADD COLUMN org_id UUID REFERENCES organizations(id);
--
-- 2. Update policies to filter by organization:
-- CREATE POLICY "Users can view own org clients"
-- ON clients
-- FOR SELECT
-- USING (
-- auth.uid() IS NOT NULL AND
-- org_id = (SELECT org_id FROM users WHERE id = auth.uid())
-- );
--
-- 3. Add role-based access:
-- CREATE POLICY "Admins can view all"
-- ON clients
-- FOR SELECT
-- USING (
-- auth.uid() IS NOT NULL AND
-- EXISTS (
-- SELECT 1 FROM users
-- WHERE id = auth.uid() AND role = 'admin'
-- )
-- );
-- ================================================
-- SECURITY NOTES
-- ================================================
-- 1. Current policies are MVP-level: all authenticated users can access all data
-- 2. In production, add org_id filtering for multi-tenancy
-- 3. ai_events table is append-only for regular users (audit trail)
-- 4. Service role can bypass RLS for admin operations
-- 5. All policies use auth.uid() for security
-- 6. Foreign key CASCADE ensures orphaned records are cleaned up

View File

@@ -0,0 +1,191 @@
-- ================================================
-- Demo Users Seed Data
-- Created: 2024-11-15
-- Epic: E2 - Database & Auth
-- Story: E2.S3 - Demo auth flow
-- ================================================
-- This migration creates demo user accounts in Supabase Auth
-- These accounts are used for public demos and presentations
-- ================================================
-- Note: This SQL creates the demo users in the auth.users table
-- The actual signup should be done via Supabase Auth API or Dashboard
-- for proper password hashing and email confirmation handling
-- ================================================
-- DEMO USER ACCOUNTS TO CREATE
-- ================================================
-- These users should be created via Supabase Dashboard or Auth API:
--
-- 1. Interactive Demo User
-- Email: demo@mini-ecd.demo
-- Password: Demo2024!
-- Access Level: Full access (can create/edit/delete)
--
-- 2. Read-Only Demo User
-- Email: readonly@mini-ecd.demo
-- Password: Demo2024!
-- Access Level: Read-only (can only view)
--
-- 3. Presenter Demo User (for live sessions)
-- Email: presenter@mini-ecd.demo
-- Password: Demo2024!
-- Access Level: Full access
--
-- ================================================
-- DEMO_USERS TRACKING TABLE (Optional - for future enhancement)
-- ================================================
-- Table to track demo user sessions and usage
-- This is optional for MVP but useful for analytics
CREATE TABLE IF NOT EXISTS demo_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
access_level TEXT NOT NULL DEFAULT 'read_only'
CHECK (access_level IN ('read_only', 'interactive', 'presenter')),
expires_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '90 days'),
usage_count INTEGER DEFAULT 0,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Metadata for tracking
notes TEXT -- Internal notes about this demo account
);
-- Index for quick lookups
CREATE INDEX idx_demo_users_user_id ON demo_users(user_id);
CREATE INDEX idx_demo_users_expires_at ON demo_users(expires_at);
-- ================================================
-- RLS POLICIES FOR DEMO_USERS TABLE
-- ================================================
-- Only authenticated users can view demo_users info
-- Only service role can manage demo_users
ALTER TABLE demo_users ENABLE ROW LEVEL SECURITY;
-- Users can view demo_users table (for checking if account is demo)
CREATE POLICY "Authenticated users can view demo users"
ON demo_users
FOR SELECT
USING (auth.uid() IS NOT NULL);
-- Only service role can insert/update/delete
-- (Regular users cannot modify via SQL, only via API with service role key)
-- ================================================
-- AUTOMATIC UPDATED_AT TRIGGER
-- ================================================
CREATE TRIGGER update_demo_users_updated_at
BEFORE UPDATE ON demo_users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ================================================
-- HELPER FUNCTION: Check if user is demo user
-- ================================================
CREATE OR REPLACE FUNCTION is_demo_user(check_user_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM demo_users
WHERE user_id = check_user_id
AND (expires_at IS NULL OR expires_at > NOW())
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ================================================
-- HELPER FUNCTION: Get demo user access level
-- ================================================
CREATE OR REPLACE FUNCTION get_demo_access_level(check_user_id UUID)
RETURNS TEXT AS $$
DECLARE
level TEXT;
BEGIN
SELECT access_level INTO level
FROM demo_users
WHERE user_id = check_user_id
AND (expires_at IS NULL OR expires_at > NOW());
RETURN level;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ================================================
-- COMMENTS
-- ================================================
COMMENT ON TABLE demo_users IS
'Tracking table for demo user accounts. Links auth.users to demo account metadata.';
COMMENT ON COLUMN demo_users.access_level IS
'Access level: read_only (view only), interactive (full CRUD), presenter (full access + special features)';
COMMENT ON COLUMN demo_users.expires_at IS
'Optional expiration date for demo account. NULL means no expiration.';
COMMENT ON COLUMN demo_users.usage_count IS
'Number of times this demo account has been used (incremented on login)';
COMMENT ON FUNCTION is_demo_user IS
'Check if a user_id belongs to an active demo account';
COMMENT ON FUNCTION get_demo_access_level IS
'Get the access level for a demo user. Returns NULL if not a demo user or expired.';
-- ================================================
-- INSTRUCTIONS FOR CREATING DEMO USERS
-- ================================================
-- Run these commands in your application code or via Supabase Dashboard:
--
-- Method 1: Via Supabase Dashboard
-- 1. Go to Authentication > Users
-- 2. Click "Add User"
-- 3. Add email + password
-- 4. Confirm email manually
-- 5. Then insert into demo_users table:
--
-- INSERT INTO demo_users (user_id, access_level, notes)
-- VALUES (
-- (SELECT id FROM auth.users WHERE email = 'demo@mini-ecd.demo'),
-- 'interactive',
-- 'Main demo account for presentations and LinkedIn demos'
-- );
--
-- Method 2: Via API (recommended for automation)
-- See: docs/DEMO_USERS_SETUP.md for setup script
-- ================================================
-- SEED DATA (to be inserted after users are created in auth.users)
-- ================================================
-- This will be executed by a separate seed script after demo users
-- are created in Supabase Auth
-- Note: Uncomment and run AFTER creating the auth.users manually
-- or via the API setup script
/*
INSERT INTO demo_users (user_id, access_level, notes, expires_at)
VALUES
(
(SELECT id FROM auth.users WHERE email = 'demo@mini-ecd.demo'),
'interactive',
'Main interactive demo account - full CRUD access',
NULL -- No expiration
),
(
(SELECT id FROM auth.users WHERE email = 'readonly@mini-ecd.demo'),
'read_only',
'Read-only demo account - view only access',
NULL -- No expiration
),
(
(SELECT id FROM auth.users WHERE email = 'presenter@mini-ecd.demo'),
'presenter',
'Presenter account for live demo sessions',
NULL -- No expiration
)
ON CONFLICT (user_id) DO NOTHING;
*/