Files
Hermes Agent 5d87e4975c Add HttpOnly cookie auth for cross-domain SPA login
Problem: Frontend JS bundle used credentials:'same-origin' which
dropped cookies on cross-domain requests. Login worked (200) but
the subsequent /auth/me check returned 401, leaving the user stuck
on the login screen.

Fix:
- AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh
- Cookie uses Domain=.fahrschultermin.de (shared between frontend
  and api subdomain), Secure, SameSite=Lax, Max-Age=8h
- JwtMiddleware reads JWT from Authorization header OR cookie
- Added AuthController::me() endpoint (was missing, caused 500)
- Logout endpoint clears the cookie
- Frontend index.php patches fetch() to use credentials:'include'
  for all /api/v1/* calls
2026-06-04 20:33:31 +02:00

370 lines
16 KiB
PL/PgSQL
Executable File

-- Migration: 001_initial_schema.sql
-- Created: 2024
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- PLATFORM ADMINS (internal team)
-- ============================================
CREATE TABLE platform_admins (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'support' CHECK (role IN ('super_admin', 'support', 'sales')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_platform_admins_email ON platform_admins(email);
-- ============================================
-- MODULES (global module definitions)
-- ============================================
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
monthly_price INTEGER NOT NULL DEFAULT 0, -- cent
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Insert default modules
INSERT INTO modules (key, name, description, monthly_price) VALUES
('office', 'Office', 'Kundenverwaltung, Fahrzeugverwaltung, Berichte', 0),
('calendar', 'Kalender', 'Terminplanung (Integration fahrschultermin.de)', 0),
('messenger', 'Messenger', 'WhatsApp-ähnlicher Messenger', 0),
('learning_blank', 'Lernsoftware (Blanko)', 'Eigene Inhalte erstellen', 0),
('learning_premium', 'Lernsoftware (Premium)', 'Inkl. vorgefertigter Lerninhalte', 0);
-- ============================================
-- PACKAGES (pricing bundles)
-- ============================================
CREATE TABLE packages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
monthly_price INTEGER NOT NULL DEFAULT 0, -- cent
modules_included JSONB DEFAULT '[]',
max_users INTEGER DEFAULT 5,
max_branches INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Insert default packages
INSERT INTO packages (key, name, description, monthly_price, modules_included, max_users, max_branches) VALUES
('starter', 'Starter', 'Basic-Paket für kleine Fahrschulen', 4900, '["office", "calendar", "messenger"]', 5, 1),
('professional', 'Professional', 'Für mittlere Fahrschulen', 9900, '["office", "calendar", "messenger", "learning_blank"]', 20, 3),
('premium', 'Premium', 'Alle Funktionen inkl. Premium-Lerninhalte', 14900, '["office", "calendar", "messenger", "learning_premium"]', 100, 10);
-- ============================================
-- TENANTS (fahrschulen / customers)
-- ============================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
phone VARCHAR(100),
address TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'pending_approval' CHECK (status IN ('pending_approval', 'active', 'suspended', 'trial')),
trial_ends_at TIMESTAMP WITH TIME ZONE,
is_self_hosted BOOLEAN DEFAULT FALSE,
self_hosted_license_key VARCHAR(255),
-- Branding
logo_url VARCHAR(500),
primary_color VARCHAR(7) DEFAULT '#2563eb',
secondary_color VARCHAR(7) DEFAULT '#64748b',
custom_css TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_email ON tenants(email);
-- ============================================
-- BRANCHES (filialen)
-- ============================================
CREATE TABLE branches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
address TEXT,
phone VARCHAR(100),
email VARCHAR(255),
opening_hours JSONB DEFAULT '{}',
is_headquarters BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_branches_tenant ON branches(tenant_id);
-- ============================================
-- USERS (tenant users)
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
branch_id UUID REFERENCES branches(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
user_type VARCHAR(50) NOT NULL DEFAULT 'student' CHECK (user_type IN ('admin', 'office', 'instructor', 'lecturer', 'student')),
status VARCHAR(50) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'pending')),
avatar_url VARCHAR(500),
phone VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_branch ON users(branch_id);
CREATE INDEX idx_users_email ON users(email);
-- ============================================
-- TENANT MODULES (module activation per tenant)
-- ============================================
CREATE TABLE tenant_modules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE RESTRICT,
status VARCHAR(50) NOT NULL DEFAULT 'enabled' CHECK (status IN ('enabled', 'disabled')),
enabled_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
trial_ends_at TIMESTAMP WITH TIME ZONE,
UNIQUE(tenant_id, module_id)
);
CREATE INDEX idx_tenant_modules_tenant ON tenant_modules(tenant_id);
-- ============================================
-- TENANT PACKAGES (active package per tenant)
-- ============================================
CREATE TABLE tenant_packages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
package_id UUID NOT NULL REFERENCES packages(id) ON DELETE RESTRICT,
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
UNIQUE(tenant_id)
);
CREATE INDEX idx_tenant_packages_tenant ON tenant_packages(tenant_id);
-- ============================================
-- LEARNING CONTENTS
-- ============================================
CREATE TABLE learning_contents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = platform global
category VARCHAR(255) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
content_type VARCHAR(50) NOT NULL CHECK (content_type IN ('video', 'document', 'quiz', 'exercise')),
content_url VARCHAR(500),
thumbnail_url VARCHAR(500),
duration_minutes INTEGER,
is_premium BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_learning_contents_tenant ON learning_contents(tenant_id);
CREATE INDEX idx_learning_contents_category ON learning_contents(category);
-- ============================================
-- STUDENT PROGRESS
-- ============================================
CREATE TABLE student_progress (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content_id UUID NOT NULL REFERENCES learning_contents(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')),
completed_at TIMESTAMP WITH TIME ZONE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, content_id)
);
CREATE INDEX idx_student_progress_user ON student_progress(user_id);
CREATE INDEX idx_student_progress_content ON student_progress(content_id);
-- ============================================
-- AUDIT LOGS
-- ============================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = platform admin action
user_id UUID, -- can be NULL if system action
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id UUID,
old_values JSONB,
new_values JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
-- ============================================
-- INVOICES
-- ============================================
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
invoice_number VARCHAR(100) NOT NULL UNIQUE,
amount INTEGER NOT NULL, -- cent
vat_amount INTEGER NOT NULL DEFAULT 0,
total_amount INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR',
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('draft', 'pending', 'paid', 'overdue', 'cancelled')),
billing_period_start DATE,
billing_period_end DATE,
due_date DATE,
paid_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_invoices_tenant ON invoices(tenant_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_due_date ON invoices(due_date);
CREATE TABLE invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price INTEGER NOT NULL, -- cent
total_price INTEGER NOT NULL
);
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
-- ============================================
-- NOTIFICATIONS
-- ============================================
CREATE TABLE notification_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
key VARCHAR(100) NOT NULL UNIQUE,
subject VARCHAR(500) NOT NULL,
body_html TEXT,
body_text TEXT,
variables JSONB DEFAULT '[]',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
template_key VARCHAR(100),
subject VARCHAR(500) NOT NULL,
body TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
sent_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_notifications_user ON notifications(user_id);
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id);
-- ============================================
-- FILES (DKS)
-- ============================================
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id UUID,
file_type VARCHAR(100) NOT NULL,
original_filename VARCHAR(500) NOT NULL,
storage_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(255),
size_bytes BIGINT,
expires_at TIMESTAMP WITH TIME ZONE, -- for temp files
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_files_tenant ON files(tenant_id);
CREATE INDEX idx_files_entity ON files(entity_type, entity_id);
CREATE INDEX idx_files_user ON files(user_id);
-- ============================================
-- MESSENGER
-- ============================================
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL DEFAULT 'direct' CHECK (type IN ('direct', 'group')),
name VARCHAR(255), -- only for groups
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_conversations_tenant ON conversations(tenant_id);
CREATE TABLE conversation_participants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_read_at TIMESTAMP WITH TIME ZONE,
UNIQUE(conversation_id, user_id)
);
CREATE INDEX idx_conversation_participants_conversation ON conversation_participants(conversation_id);
CREATE INDEX idx_conversation_participants_user ON conversation_participants(user_id);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
is_edited BOOLEAN DEFAULT FALSE,
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_messages_conversation ON messages(conversation_id);
CREATE INDEX idx_messages_created ON messages(created_at);
-- ============================================
-- UPDATE TIMESTAMP TRIGGER
-- ============================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_platform_admins_updated_at BEFORE UPDATE ON platform_admins FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_branches_updated_at BEFORE UPDATE ON branches FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_learning_contents_updated_at BEFORE UPDATE ON learning_contents FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_student_progress_updated_at BEFORE UPDATE ON student_progress FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_invoices_updated_at BEFORE UPDATE ON invoices FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_conversations_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tr_messages_updated_at BEFORE UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_updated_at();