[split-required] [guardrail-change] Enforce 500 LOC budget across all services

Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 00:09:30 +02:00
parent 5ef039a6bc
commit 92c86ec6ba
162 changed files with 23853 additions and 23034 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
package database
import (
"context"
"fmt"
)
// migrateCore creates the core tables: users, auth tokens, sessions,
// documents, versions, consents, cookies, audit, notifications,
// deadlines, suspensions, and their indexes (Phases 1-5).
func migrateCore(db *DB) error {
ctx := context.Background()
migrations := []string{
// Users table (extended for full auth)
`CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(255) UNIQUE,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
name VARCHAR(255),
role VARCHAR(50) DEFAULT 'user',
email_verified BOOLEAN DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
account_status VARCHAR(20) DEFAULT 'active',
last_login_at TIMESTAMPTZ,
failed_login_attempts INT DEFAULT 0,
locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Legal documents table
`CREATE TABLE IF NOT EXISTS legal_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
is_mandatory BOOLEAN DEFAULT true,
is_active BOOLEAN DEFAULT true,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Document versions table
`CREATE TABLE IF NOT EXISTS document_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE,
version VARCHAR(20) NOT NULL,
language VARCHAR(5) DEFAULT 'de',
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
summary TEXT,
status VARCHAR(20) DEFAULT 'draft',
published_at TIMESTAMPTZ,
scheduled_publish_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
approved_by UUID REFERENCES users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(document_id, version, language)
)`,
// Add scheduled_publish_at column if not exists (migration)
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`,
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`,
// User consents table
`CREATE TABLE IF NOT EXISTS user_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
document_version_id UUID REFERENCES document_versions(id),
consented BOOLEAN NOT NULL,
ip_address INET,
user_agent TEXT,
consented_at TIMESTAMPTZ DEFAULT NOW(),
withdrawn_at TIMESTAMPTZ,
UNIQUE(user_id, document_version_id)
)`,
// Cookie categories table
`CREATE TABLE IF NOT EXISTS cookie_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
display_name_de VARCHAR(255) NOT NULL,
display_name_en VARCHAR(255),
description_de TEXT,
description_en TEXT,
is_mandatory BOOLEAN DEFAULT false,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Cookie consents table
`CREATE TABLE IF NOT EXISTS cookie_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
category_id UUID REFERENCES cookie_categories(id) ON DELETE CASCADE,
consented BOOLEAN NOT NULL,
consented_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, category_id)
)`,
// Audit log table
`CREATE TABLE IF NOT EXISTS consent_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50),
entity_id UUID,
details JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Data export requests table
`CREATE TABLE IF NOT EXISTS data_export_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending',
download_url TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
)`,
// Data deletion requests table
`CREATE TABLE IF NOT EXISTS data_deletion_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'pending',
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
processed_by UUID REFERENCES users(id)
)`,
// =============================================
// Phase 1: User Management Tables
// =============================================
// Email verification tokens
`CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Password reset tokens
`CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// User sessions (for JWT revocation and session management)
`CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
device_info TEXT,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_activity_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Phase 3: Version Approvals (DSB Workflow)
// =============================================
// Version approval tracking
`CREATE TABLE IF NOT EXISTS version_approvals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
approver_id UUID REFERENCES users(id),
action VARCHAR(30) NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Phase 4: Notification System
// =============================================
// Notifications
`CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
channel VARCHAR(20) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
data JSONB,
read_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Push subscriptions for Web Push
`CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, endpoint)
)`,
// Notification preferences per user
`CREATE TABLE IF NOT EXISTS notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
email_enabled BOOLEAN DEFAULT TRUE,
push_enabled BOOLEAN DEFAULT TRUE,
in_app_enabled BOOLEAN DEFAULT TRUE,
reminder_frequency VARCHAR(20) DEFAULT 'weekly',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Phase 5: Consent Deadlines & Account Suspension
// =============================================
// Consent deadlines per user per version
`CREATE TABLE IF NOT EXISTS consent_deadlines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
document_version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
deadline_at TIMESTAMPTZ NOT NULL,
reminder_count INT DEFAULT 0,
last_reminder_at TIMESTAMPTZ,
consent_given_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, document_version_id)
)`,
// Account suspensions tracking
`CREATE TABLE IF NOT EXISTS account_suspensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
reason VARCHAR(50) NOT NULL,
details JSONB,
suspended_at TIMESTAMPTZ DEFAULT NOW(),
lifted_at TIMESTAMPTZ,
lifted_reason TEXT
)`,
// =============================================
// Indexes for performance
// =============================================
`CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`,
`CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`,
`CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`,
`CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`,
// Phase 1: Auth indexes
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`,
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`,
`CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`,
// Phase 3: Approval indexes
`CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`,
// Phase 4: Notification indexes
`CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`,
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`,
// Phase 5: Deadline indexes
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`,
`CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`,
}
for _, migration := range migrations {
if _, err := db.Pool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migrateCore: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,267 @@
package database
import (
"context"
"fmt"
)
// migrateDSR creates DSGVO Data Subject Request tables (Phase 10)
// and EduSearch seed management tables (Phase 11).
func migrateDSR(db *DB) error {
ctx := context.Background()
migrations := []string{
// =============================================
// Phase 10: DSGVO Betroffenenanfragen (DSR)
// Data Subject Request Management
// =============================================
// Sequence for request numbers
`CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`,
// Main table: Data Subject Requests
`CREATE TABLE IF NOT EXISTS data_subject_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
request_number VARCHAR(50) UNIQUE NOT NULL,
request_type VARCHAR(30) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'intake',
priority VARCHAR(20) DEFAULT 'normal',
source VARCHAR(30) NOT NULL DEFAULT 'api',
requester_email VARCHAR(255) NOT NULL,
requester_name VARCHAR(255),
requester_phone VARCHAR(50),
identity_verified BOOLEAN DEFAULT FALSE,
identity_verified_at TIMESTAMPTZ,
identity_verified_by UUID REFERENCES users(id),
identity_verification_method VARCHAR(50),
request_details JSONB DEFAULT '{}',
deadline_at TIMESTAMPTZ NOT NULL,
legal_deadline_days INT NOT NULL,
extended_deadline_at TIMESTAMPTZ,
extension_reason TEXT,
assigned_to UUID REFERENCES users(id),
processing_notes TEXT,
completed_at TIMESTAMPTZ,
completed_by UUID REFERENCES users(id),
result_summary TEXT,
result_data JSONB,
rejected_at TIMESTAMPTZ,
rejected_by UUID REFERENCES users(id),
rejection_reason TEXT,
rejection_legal_basis TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id)
)`,
// DSR Status History for audit trail
`CREATE TABLE IF NOT EXISTS dsr_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
from_status VARCHAR(30),
to_status VARCHAR(30) NOT NULL,
changed_by UUID REFERENCES users(id),
comment TEXT,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// DSR Communications log
`CREATE TABLE IF NOT EXISTS dsr_communications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
direction VARCHAR(10) NOT NULL,
channel VARCHAR(20) NOT NULL,
communication_type VARCHAR(50) NOT NULL,
template_version_id UUID,
subject VARCHAR(500),
body_html TEXT,
body_text TEXT,
recipient_email VARCHAR(255),
sent_at TIMESTAMPTZ,
error_message TEXT,
attachments JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id)
)`,
// DSR Templates
`CREATE TABLE IF NOT EXISTS dsr_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_type VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]',
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// DSR Template Versions
`CREATE TABLE IF NOT EXISTS dsr_template_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE,
version VARCHAR(20) NOT NULL,
language VARCHAR(5) DEFAULT 'de',
subject VARCHAR(500) NOT NULL,
body_html TEXT NOT NULL,
body_text TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'draft',
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
approved_by UUID REFERENCES users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(template_id, version, language)
)`,
// DSR Exception Checks (for Art. 17(3) erasure exceptions)
`CREATE TABLE IF NOT EXISTS dsr_exception_checks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
exception_type VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
applies BOOLEAN,
checked_by UUID REFERENCES users(id),
checked_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Phase 10 Indexes
`CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`,
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`,
// Insert default DSR templates
`INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order)
VALUES
('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1),
('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2),
('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3),
('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4),
('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5),
('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6),
('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7),
('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8),
('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9),
('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10),
('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11),
('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12),
('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13),
('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14),
('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15),
('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16),
('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17),
('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18),
('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19)
ON CONFLICT (template_type) DO NOTHING`,
// =============================================
// Phase 11: EduSearch Seeds Management
// Seed URLs for the education search crawler
// =============================================
// EduSearch Seed Categories
`CREATE TABLE IF NOT EXISTS edu_search_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(10),
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// EduSearch Seeds (crawler seed URLs)
`CREATE TABLE IF NOT EXISTS edu_search_seeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url VARCHAR(500) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL,
source_type VARCHAR(20) DEFAULT 'GOV',
scope VARCHAR(20) DEFAULT 'FEDERAL',
state VARCHAR(5),
trust_boost DECIMAL(3,2) DEFAULT 0.50,
enabled BOOLEAN DEFAULT TRUE,
crawl_depth INT DEFAULT 2,
crawl_frequency VARCHAR(20) DEFAULT 'weekly',
last_crawled_at TIMESTAMPTZ,
last_crawl_status VARCHAR(20),
last_crawl_docs INT DEFAULT 0,
total_documents INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id)
)`,
// EduSearch Crawl Runs (history of crawl executions)
`CREATE TABLE IF NOT EXISTS edu_search_crawl_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'running',
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
pages_crawled INT DEFAULT 0,
documents_indexed INT DEFAULT 0,
errors_count INT DEFAULT 0,
error_details JSONB,
triggered_by UUID REFERENCES users(id)
)`,
// EduSearch Denylist (URLs/domains to never crawl)
`CREATE TABLE IF NOT EXISTS edu_search_denylist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pattern VARCHAR(500) UNIQUE NOT NULL,
pattern_type VARCHAR(20) DEFAULT 'domain',
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id)
)`,
// Phase 11 Indexes
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`,
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`,
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`,
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`,
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`,
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`,
// Insert default EduSearch categories
`INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order)
VALUES
('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1),
('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2),
('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3),
('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4),
('schools', 'Schulen', 'Schulwebsites', '🏫', 5),
('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6),
('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7),
('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8)
ON CONFLICT (name) DO NOTHING`,
}
for _, migration := range migrations {
if _, err := db.Pool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migrateDSR: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,114 @@
package database
import (
"context"
"fmt"
)
// migrateEmail creates email template tables, settings, and indexes (Phase 8).
func migrateEmail(db *DB) error {
ctx := context.Background()
migrations := []string{
// =============================================
// Phase 8: E-Mail Templates (Transactional)
// =============================================
// Email templates (like legal_documents)
`CREATE TABLE IF NOT EXISTS email_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Email template versions (like document_versions)
`CREATE TABLE IF NOT EXISTS email_template_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE,
version VARCHAR(20) NOT NULL,
language VARCHAR(5) DEFAULT 'de',
subject VARCHAR(500) NOT NULL,
body_html TEXT NOT NULL,
body_text TEXT NOT NULL,
summary TEXT,
status VARCHAR(20) DEFAULT 'draft',
published_at TIMESTAMPTZ,
scheduled_publish_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
approved_by UUID REFERENCES users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(template_id, version, language)
)`,
// Email template approvals (like version_approvals)
`CREATE TABLE IF NOT EXISTS email_template_approvals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE,
approver_id UUID REFERENCES users(id),
action VARCHAR(30) NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Email send logs for audit
`CREATE TABLE IF NOT EXISTS email_send_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL,
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
status VARCHAR(20) DEFAULT 'queued',
error_msg TEXT,
variables JSONB,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Global email settings (logo, colors, signature)
`CREATE TABLE IF NOT EXISTS email_template_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
logo_url TEXT,
logo_base64 TEXT,
company_name VARCHAR(255) DEFAULT 'BreakPilot',
sender_name VARCHAR(255) DEFAULT 'BreakPilot',
sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app',
reply_to_email VARCHAR(255),
footer_html TEXT,
footer_text TEXT,
primary_color VARCHAR(7) DEFAULT '#2563eb',
secondary_color VARCHAR(7) DEFAULT '#64748b',
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
)`,
// Insert default email settings
`INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color)
VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b')
ON CONFLICT DO NOTHING`,
// Phase 8 Indexes
`CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`,
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`,
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`,
`CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`,
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`,
}
for _, migration := range migrations {
if _, err := db.Pool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migrateEmail: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,171 @@
package database
import (
"context"
"fmt"
)
// migrateOAuth creates OAuth 2.0 and 2FA tables (Phases 6-7),
// plus default seed data for OAuth clients, cookie categories,
// and legal documents.
func migrateOAuth(db *DB) error {
ctx := context.Background()
migrations := []string{
// =============================================
// Phase 6: OAuth 2.0 Authorization Code Flow
// =============================================
// OAuth 2.0 Clients
`CREATE TABLE IF NOT EXISTS oauth_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id VARCHAR(64) UNIQUE NOT NULL,
client_secret VARCHAR(255),
name VARCHAR(255) NOT NULL,
description TEXT,
redirect_uris JSONB NOT NULL DEFAULT '[]',
scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]',
grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]',
is_public BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// OAuth 2.0 Authorization Codes
`CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(255) UNIQUE NOT NULL,
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
scopes JSONB NOT NULL DEFAULT '[]',
code_challenge VARCHAR(255),
code_challenge_method VARCHAR(10),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// OAuth 2.0 Access Tokens
`CREATE TABLE IF NOT EXISTS oauth_access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(255) UNIQUE NOT NULL,
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes JSONB NOT NULL DEFAULT '[]',
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// OAuth 2.0 Refresh Tokens
`CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(255) UNIQUE NOT NULL,
access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE,
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes JSONB NOT NULL DEFAULT '[]',
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Phase 7: Two-Factor Authentication (2FA/TOTP)
// =============================================
// User TOTP secrets and recovery codes
`CREATE TABLE IF NOT EXISTS user_totp (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
secret VARCHAR(255) NOT NULL,
verified BOOLEAN DEFAULT FALSE,
recovery_codes JSONB DEFAULT '[]',
enabled_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// 2FA challenges during login
`CREATE TABLE IF NOT EXISTS two_factor_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
challenge_id VARCHAR(255) UNIQUE NOT NULL,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Add 2FA required flag to users
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`,
// Phase 6 & 7 Indexes
`CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`,
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`,
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`,
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`,
// Insert default OAuth client for BreakPilot PWA (public client with PKCE)
`INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public)
VALUES (
'breakpilot-pwa',
'BreakPilot PWA',
'Official BreakPilot Progressive Web Application',
'["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]',
'["openid", "profile", "email", "consent:read", "consent:write"]',
'["authorization_code", "refresh_token"]',
true
) ON CONFLICT (client_id) DO NOTHING`,
// Insert default cookie categories
`INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
VALUES
('necessary', 'Notwendige Cookies', 'Necessary Cookies',
'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.',
'These cookies are essential for the basic functions of the website.',
true, 1),
('functional', 'Funktionale Cookies', 'Functional Cookies',
'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.',
'These cookies enable enhanced functionality and personalization.',
false, 2),
('analytics', 'Analyse Cookies', 'Analytics Cookies',
'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.',
'These cookies help us understand how visitors interact with the website.',
false, 3),
('marketing', 'Marketing Cookies', 'Marketing Cookies',
'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.',
'These cookies are used to make advertising more relevant to you.',
false, 4)
ON CONFLICT (name) DO NOTHING`,
// Insert default legal documents
`INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order)
VALUES
('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1),
('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2),
('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3),
('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4)
ON CONFLICT DO NOTHING`,
}
for _, migration := range migrations {
if _, err := db.Pool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migrateOAuth: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,182 @@
package database
import (
"context"
"fmt"
)
// migrateSchool creates school management tables: schools, classes,
// students, teachers, parents, timetable, attendance, grades,
// class diary, parent meetings, Matrix integration (Phase 9).
func migrateSchool(db *DB) error {
ctx := context.Background()
migrations := []string{
// =============================================
// Phase 9: Schulverwaltung / School Management
// Matrix-basierte Kommunikation für Schulen
// =============================================
// Schools table
`CREATE TABLE IF NOT EXISTS schools (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
short_name VARCHAR(50),
type VARCHAR(50) NOT NULL,
address TEXT,
city VARCHAR(100),
postal_code VARCHAR(20),
state VARCHAR(50),
country VARCHAR(2) DEFAULT 'DE',
phone VARCHAR(50),
email VARCHAR(255),
website VARCHAR(255),
matrix_server_name VARCHAR(255),
logo_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// School years
`CREATE TABLE IF NOT EXISTS school_years (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name VARCHAR(20) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
is_current BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(school_id, name)
)`,
// Subjects
`CREATE TABLE IF NOT EXISTS subjects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
short_name VARCHAR(10) NOT NULL,
color VARCHAR(7),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(school_id, short_name)
)`,
// Classes
`CREATE TABLE IF NOT EXISTS classes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
name VARCHAR(20) NOT NULL,
grade INT NOT NULL,
section VARCHAR(5),
room VARCHAR(50),
matrix_info_room VARCHAR(255),
matrix_rep_room VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(school_id, school_year_id, name)
)`,
// Students
`CREATE TABLE IF NOT EXISTS students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
student_number VARCHAR(50),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
date_of_birth DATE,
gender VARCHAR(1),
matrix_user_id VARCHAR(255),
matrix_dm_room VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Teachers
`CREATE TABLE IF NOT EXISTS teachers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
teacher_code VARCHAR(10),
title VARCHAR(20),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
matrix_user_id VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(school_id, user_id)
)`,
// Class teachers assignment
`CREATE TABLE IF NOT EXISTS class_teachers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(class_id, teacher_id)
)`,
// Teacher subjects assignment
`CREATE TABLE IF NOT EXISTS teacher_subjects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(teacher_id, subject_id)
)`,
// Parents
`CREATE TABLE IF NOT EXISTS parents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
matrix_user_id VARCHAR(255),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
phone VARCHAR(50),
emergency_contact BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id)
)`,
// Student-parent relationships
`CREATE TABLE IF NOT EXISTS student_parents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
relationship VARCHAR(20) NOT NULL,
is_primary BOOLEAN DEFAULT FALSE,
has_custody BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(student_id, parent_id)
)`,
// Parent representatives
`CREATE TABLE IF NOT EXISTS parent_representatives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
elected_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
}
for _, migration := range migrations {
if _, err := db.Pool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migrateSchool: %w", err)
}
}
// Run the second batch (timetable, attendance, grades, etc.)
return migrateSchoolPart2(db)
}

View File

@@ -0,0 +1,346 @@
package database
import (
"context"
"fmt"
)
// migrateSchoolPart2 creates timetable, attendance, grades, diary,
// meetings, Matrix, and Phase 9 indexes/seed data.
func migrateSchoolPart2(db *DB) error {
ctx := context.Background()
migrations := []string{
// =============================================
// Stundenplan / Timetable
// =============================================
// Timetable slots (Stundenraster)
`CREATE TABLE IF NOT EXISTS timetable_slots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
slot_number INT NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_break BOOLEAN DEFAULT FALSE,
name VARCHAR(50),
UNIQUE(school_id, slot_number)
)`,
// Timetable entries (Stundenplan)
`CREATE TABLE IF NOT EXISTS timetable_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7),
room VARCHAR(50),
valid_from DATE NOT NULL,
valid_until DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Timetable substitutions (Vertretungsplan)
`CREATE TABLE IF NOT EXISTS timetable_substitutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE,
date DATE NOT NULL,
substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL,
substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL,
room VARCHAR(50),
type VARCHAR(20) NOT NULL,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID NOT NULL REFERENCES users(id)
)`,
// =============================================
// Abwesenheit / Attendance
// =============================================
// Attendance records per lesson
`CREATE TABLE IF NOT EXISTS attendance_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL,
date DATE NOT NULL,
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
status VARCHAR(30) NOT NULL,
recorded_by UUID NOT NULL REFERENCES users(id),
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(student_id, date, slot_id)
)`,
// Absence reports (Krankmeldungen)
`CREATE TABLE IF NOT EXISTS absence_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
reason TEXT,
reason_category VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'reported',
reported_by UUID NOT NULL REFERENCES users(id),
reported_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_by UUID REFERENCES users(id),
confirmed_at TIMESTAMPTZ,
medical_certificate BOOLEAN DEFAULT FALSE,
certificate_uploaded BOOLEAN DEFAULT FALSE,
matrix_notification_sent BOOLEAN DEFAULT FALSE,
email_notification_sent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Absence notifications to parents
`CREATE TABLE IF NOT EXISTS absence_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL,
message_content TEXT NOT NULL,
sent_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
response_received BOOLEAN DEFAULT FALSE,
response_content TEXT,
response_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Notenspiegel / Grades
// =============================================
// Grade scales
`CREATE TABLE IF NOT EXISTS grade_scales (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
min_value DECIMAL(5,2) NOT NULL,
max_value DECIMAL(5,2) NOT NULL,
passing_value DECIMAL(5,2) NOT NULL,
is_ascending BOOLEAN DEFAULT FALSE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Grades
`CREATE TABLE IF NOT EXISTS grades (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE,
type VARCHAR(30) NOT NULL,
value DECIMAL(5,2) NOT NULL,
weight DECIMAL(3,2) DEFAULT 1.0,
date DATE NOT NULL,
title VARCHAR(100),
description TEXT,
is_visible BOOLEAN DEFAULT TRUE,
semester INT NOT NULL CHECK (semester IN (1, 2)),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Grade comments
`CREATE TABLE IF NOT EXISTS grade_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
comment TEXT NOT NULL,
is_private BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Klassenbuch / Class Diary
// =============================================
// Class diary entries
`CREATE TABLE IF NOT EXISTS class_diary_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
date DATE NOT NULL,
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
topic TEXT,
homework TEXT,
homework_due_date DATE,
materials TEXT,
notes TEXT,
is_cancelled BOOLEAN DEFAULT FALSE,
cancellation_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(class_id, date, slot_id)
)`,
// =============================================
// Elterngespräche / Parent Meetings
// =============================================
// Parent meeting slots
`CREATE TABLE IF NOT EXISTS parent_meeting_slots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
location VARCHAR(100),
is_online BOOLEAN DEFAULT FALSE,
meeting_link TEXT,
is_booked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Parent meetings
`CREATE TABLE IF NOT EXISTS parent_meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
topic TEXT,
notes TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
cancelled_at TIMESTAMPTZ,
cancelled_by UUID REFERENCES users(id),
cancel_reason TEXT,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// =============================================
// Matrix / Communication Integration
// =============================================
// Matrix rooms
`CREATE TABLE IF NOT EXISTS matrix_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
matrix_room_id VARCHAR(255) NOT NULL UNIQUE,
type VARCHAR(30) NOT NULL,
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
student_id UUID REFERENCES students(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
is_encrypted BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Matrix room members
`CREATE TABLE IF NOT EXISTS matrix_room_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE,
matrix_user_id VARCHAR(255) NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
power_level INT DEFAULT 0,
can_write BOOLEAN DEFAULT TRUE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
left_at TIMESTAMPTZ,
UNIQUE(matrix_room_id, matrix_user_id)
)`,
// Parent onboarding tokens (QR codes)
`CREATE TABLE IF NOT EXISTS parent_onboarding_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
role VARCHAR(30) NOT NULL DEFAULT 'parent',
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
used_by_user_id UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID NOT NULL REFERENCES users(id)
)`,
// =============================================
// Phase 9 Indexes
// =============================================
`CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`,
`CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`,
`CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`,
`CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`,
`CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`,
`CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`,
`CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`,
`CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`,
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`,
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`,
`CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`,
`CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`,
`CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`,
`CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`,
`CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`,
`CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`,
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`,
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`,
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`,
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`,
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`,
`CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`,
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`,
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`,
// Insert default grade scales
`INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default)
SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true
FROM schools s
WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)')
ON CONFLICT DO NOTHING`,
// Insert default timetable slots for schools
`DO $$
DECLARE
school_rec RECORD;
BEGIN
FOR school_rec IN SELECT id FROM schools LOOP
INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name)
VALUES
(school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'),
(school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'),
(school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'),
(school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'),
(school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'),
(school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'),
(school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'),
(school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'),
(school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'),
(school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'),
(school_rec.id, 11, '14:45', '15:30', false, '8. Stunde')
ON CONFLICT (school_id, slot_number) DO NOTHING;
END LOOP;
END $$`,
}
for _, migration := range migrations {
if _, err := db.Pool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migrateSchool: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,455 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// ADMIN ENDPOINTS - Version Approval Workflow (DSB)
// ========================================
// AdminSubmitForReview submits a version for DSB review
func (h *Handler) AdminSubmitForReview(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Check current status
var status string
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
if status != "draft" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"})
return
}
// Update status to review
_, err = h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = 'review', updated_at = NOW()
WHERE id = $1
`, versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"})
return
}
// Log approval action
_, err = h.db.Pool.Exec(ctx, `
INSERT INTO version_approvals (version_id, approver_id, action, comment)
VALUES ($1, $2, 'submitted', 'Submitted for DSB review')
`, versionID, userID)
h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent)
c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"})
}
// AdminApproveVersion approves a version with scheduled publish date (DSB only)
func (h *Handler) AdminApproveVersion(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
// Check if user is DSB or Admin (for dev purposes)
if !middleware.IsDSB(c) && !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"})
return
}
var req struct {
Comment string `json:"comment"`
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z"
}
c.ShouldBindJSON(&req)
// Validate scheduled publish date
var scheduledAt *time.Time
if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" {
parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"})
return
}
if parsed.Before(time.Now()) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"})
return
}
scheduledAt = &parsed
}
userID, _ := middleware.GetUserID(c)
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Check current status
var status string
var createdBy *uuid.UUID
err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
if status != "review" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"})
return
}
// Four-eyes principle: DSB cannot approve their own version
// Exception: Admins can approve their own versions for development/testing purposes
role, _ := c.Get("role")
roleStr, _ := role.(string)
if createdBy != nil && *createdBy == userID && roleStr != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"})
return
}
// Determine new status: 'scheduled' if date set, otherwise 'approved'
newStatus := "approved"
if scheduledAt != nil {
newStatus = "scheduled"
}
// Update status to approved/scheduled
_, err = h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW()
WHERE id = $1
`, versionID, newStatus, userID, scheduledAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"})
return
}
// Log approval action
comment := req.Comment
if comment == "" {
if scheduledAt != nil {
comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04")
} else {
comment = "Approved by DSB"
}
}
_, err = h.db.Pool.Exec(ctx, `
INSERT INTO version_approvals (version_id, approver_id, action, comment)
VALUES ($1, $2, 'approved', $3)
`, versionID, userID, comment)
h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent)
response := gin.H{"message": "Version approved", "status": newStatus}
if scheduledAt != nil {
response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339)
}
c.JSON(http.StatusOK, response)
}
// AdminRejectVersion rejects a version (DSB only)
func (h *Handler) AdminRejectVersion(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
// Check if user is DSB
if !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"})
return
}
var req struct {
Comment string `json:"comment" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Check current status
var status string
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
if status != "review" && status != "approved" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"})
return
}
// Update status back to draft
_, err = h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = 'draft', approved_by = NULL, updated_at = NOW()
WHERE id = $1
`, versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"})
return
}
// Log rejection
_, err = h.db.Pool.Exec(ctx, `
INSERT INTO version_approvals (version_id, approver_id, action, comment)
VALUES ($1, $2, 'rejected', $3)
`, versionID, userID, req.Comment)
h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent)
c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"})
}
// AdminCompareVersions returns two versions for side-by-side comparison
func (h *Handler) AdminCompareVersions(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
ctx := context.Background()
// Get the current version and its document
var currentVersion models.DocumentVersion
var documentID uuid.UUID
err = h.db.Pool.QueryRow(ctx, `
SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at
FROM document_versions
WHERE id = $1
`, versionID).Scan(&currentVersion.ID, &documentID, &currentVersion.Version, &currentVersion.Language,
&currentVersion.Title, &currentVersion.Content, &currentVersion.Summary, &currentVersion.Status,
&currentVersion.CreatedAt, &currentVersion.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
// Get the currently published version (if any)
var publishedVersion *models.DocumentVersion
var pv models.DocumentVersion
err = h.db.Pool.QueryRow(ctx, `
SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at
FROM document_versions
WHERE document_id = $1 AND language = $2 AND status = 'published'
ORDER BY published_at DESC
LIMIT 1
`, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language,
&pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt)
if err == nil && pv.ID != currentVersion.ID {
publishedVersion = &pv
}
// Get approval history
rows, err := h.db.Pool.Query(ctx, `
SELECT va.action, va.comment, va.created_at, u.email
FROM version_approvals va
LEFT JOIN users u ON va.approver_id = u.id
WHERE va.version_id = $1
ORDER BY va.created_at DESC
`, versionID)
var approvalHistory []map[string]interface{}
if err == nil {
defer rows.Close()
for rows.Next() {
var action, email string
var comment *string
var createdAt time.Time
if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil {
approvalHistory = append(approvalHistory, map[string]interface{}{
"action": action,
"comment": comment,
"created_at": createdAt,
"approver": email,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"current_version": currentVersion,
"published_version": publishedVersion,
"approval_history": approvalHistory,
})
}
// AdminGetApprovalHistory returns the approval history for a version
func (h *Handler) AdminGetApprovalHistory(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name
FROM version_approvals va
LEFT JOIN users u ON va.approver_id = u.id
WHERE va.version_id = $1
ORDER BY va.created_at DESC
`, versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"})
return
}
defer rows.Close()
var history []map[string]interface{}
for rows.Next() {
var id uuid.UUID
var action string
var comment *string
var createdAt time.Time
var email, name *string
if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil {
continue
}
history = append(history, map[string]interface{}{
"id": id,
"action": action,
"comment": comment,
"created_at": createdAt,
"approver": email,
"name": name,
})
}
c.JSON(http.StatusOK, gin.H{"approval_history": history})
}
// ========================================
// SCHEDULED PUBLISHING
// ========================================
// ProcessScheduledPublishing publishes all versions that are due
// This should be called by a cron job or scheduler
func (h *Handler) ProcessScheduledPublishing(c *gin.Context) {
ctx := context.Background()
// Find all scheduled versions that are due
rows, err := h.db.Pool.Query(ctx, `
SELECT id, document_id, version
FROM document_versions
WHERE status = 'scheduled'
AND scheduled_publish_at IS NOT NULL
AND scheduled_publish_at <= NOW()
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
return
}
defer rows.Close()
var published []string
for rows.Next() {
var versionID, docID uuid.UUID
var version string
if err := rows.Scan(&versionID, &docID, &version); err != nil {
continue
}
// Publish this version
_, err := h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = 'published', published_at = NOW(), updated_at = NOW()
WHERE id = $1
`, versionID)
if err == nil {
// Archive previous published versions for this document
h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = 'archived', updated_at = NOW()
WHERE document_id = $1 AND id != $2 AND status = 'published'
`, docID, versionID)
// Log the publishing
details := fmt.Sprintf("Version %s automatically published by scheduler", version)
h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler")
published = append(published, version)
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Scheduled publishing processed",
"published_count": len(published),
"published_versions": published,
})
}
// GetScheduledVersions returns all versions scheduled for publishing
func (h *Handler) GetScheduledVersions(c *gin.Context) {
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name
FROM document_versions dv
JOIN legal_documents ld ON ld.id = dv.document_id
WHERE dv.status = 'scheduled'
AND dv.scheduled_publish_at IS NOT NULL
ORDER BY dv.scheduled_publish_at ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
return
}
defer rows.Close()
type ScheduledVersion struct {
ID uuid.UUID `json:"id"`
DocumentID uuid.UUID `json:"document_id"`
Version string `json:"version"`
Title string `json:"title"`
ScheduledPublishAt *time.Time `json:"scheduled_publish_at"`
DocumentName string `json:"document_name"`
}
var versions []ScheduledVersion
for rows.Next() {
var v ScheduledVersion
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil {
continue
}
versions = append(versions, v)
}
c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions})
}

View File

@@ -0,0 +1,391 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// ADMIN ENDPOINTS - Document Management
// ========================================
// AdminGetDocuments returns all documents (including inactive) for admin
func (h *Handler) AdminGetDocuments(c *gin.Context) {
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
FROM legal_documents
ORDER BY sort_order ASC, created_at DESC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
return
}
defer rows.Close()
var documents []models.LegalDocument
for rows.Next() {
var doc models.LegalDocument
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
continue
}
documents = append(documents, doc)
}
c.JSON(http.StatusOK, gin.H{"documents": documents})
}
// AdminCreateDocument creates a new legal document
func (h *Handler) AdminCreateDocument(c *gin.Context) {
var req models.CreateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
var docID uuid.UUID
err := h.db.Pool.QueryRow(ctx, `
INSERT INTO legal_documents (type, name, description, is_mandatory)
VALUES ($1, $2, $3, $4)
RETURNING id
`, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Document created successfully",
"id": docID,
})
}
// AdminUpdateDocument updates a legal document
func (h *Handler) AdminUpdateDocument(c *gin.Context) {
docID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
IsMandatory *bool `json:"is_mandatory"`
IsActive *bool `json:"is_active"`
SortOrder *int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
result, err := h.db.Pool.Exec(ctx, `
UPDATE legal_documents
SET name = COALESCE($2, name),
description = COALESCE($3, description),
is_mandatory = COALESCE($4, is_mandatory),
is_active = COALESCE($5, is_active),
sort_order = COALESCE($6, sort_order),
updated_at = NOW()
WHERE id = $1
`, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"})
}
// AdminDeleteDocument soft-deletes a document (sets is_active to false)
func (h *Handler) AdminDeleteDocument(c *gin.Context) {
docID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
ctx := context.Background()
result, err := h.db.Pool.Exec(ctx, `
UPDATE legal_documents
SET is_active = false, updated_at = NOW()
WHERE id = $1
`, docID)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
}
// ========================================
// ADMIN ENDPOINTS - Version Management
// ========================================
// AdminGetVersions returns all versions for a document
func (h *Handler) AdminGetVersions(c *gin.Context) {
docID, err := uuid.Parse(c.Param("docId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, document_id, version, language, title, content, summary, status,
published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at
FROM document_versions
WHERE document_id = $1
ORDER BY created_at DESC
`, docID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
return
}
defer rows.Close()
var versions []models.DocumentVersion
for rows.Next() {
var v models.DocumentVersion
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content,
&v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
versions = append(versions, v)
}
c.JSON(http.StatusOK, gin.H{"versions": versions})
}
// AdminCreateVersion creates a new document version
func (h *Handler) AdminCreateVersion(c *gin.Context) {
var req models.CreateVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
docID, err := uuid.Parse(req.DocumentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := context.Background()
var versionID uuid.UUID
err = h.db.Pool.QueryRow(ctx, `
INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
RETURNING id
`, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID)
if err != nil {
// Check for unique constraint violation
errStr := err.Error()
if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") {
c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"})
return
}
// Log the actual error for debugging
fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Version created successfully",
"id": versionID,
})
}
// AdminUpdateVersion updates a document version
func (h *Handler) AdminUpdateVersion(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
var req models.UpdateVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
// Check if version is in draft or review status (only these can be edited)
var status string
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
if status != "draft" && status != "review" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"})
return
}
result, err := h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET title = COALESCE($2, title),
content = COALESCE($3, content),
summary = COALESCE($4, summary),
status = COALESCE($5, status),
updated_at = NOW()
WHERE id = $1
`, versionID, req.Title, req.Content, req.Summary, req.Status)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"})
}
// AdminPublishVersion publishes a document version
func (h *Handler) AdminPublishVersion(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := context.Background()
// Check current status
var status string
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
if status != "approved" && status != "review" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"})
return
}
result, err := h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = 'published',
published_at = NOW(),
approved_by = $2,
updated_at = NOW()
WHERE id = $1
`, versionID, userID)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"})
}
// AdminArchiveVersion archives a document version
func (h *Handler) AdminArchiveVersion(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
ctx := context.Background()
result, err := h.db.Pool.Exec(ctx, `
UPDATE document_versions
SET status = 'archived', updated_at = NOW()
WHERE id = $1
`, versionID)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"})
}
// AdminDeleteVersion permanently deletes a draft/rejected version
// Only draft and rejected versions can be deleted. Published versions must be archived.
func (h *Handler) AdminDeleteVersion(c *gin.Context) {
versionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
ctx := context.Background()
// First check the version status - only draft/rejected can be deleted
var status string
var version string
var docID uuid.UUID
err = h.db.Pool.QueryRow(ctx, `
SELECT status, version, document_id FROM document_versions WHERE id = $1
`, versionID).Scan(&status, &version, &docID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
return
}
// Only allow deletion of draft and rejected versions
if status != "draft" && status != "rejected" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cannot delete version",
"message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.",
"status": status,
})
return
}
// Delete the version
result, err := h.db.Pool.Exec(ctx, `
DELETE FROM document_versions WHERE id = $1
`, versionID)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"})
return
}
// Log the deletion
userID, _ := c.Get("user_id")
h.db.Pool.Exec(ctx, `
INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent)
VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5)
`, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent())
c.JSON(http.StatusOK, gin.H{
"message": "Version deleted successfully",
"deleted_version": version,
"version_id": versionID,
})
}

View File

@@ -0,0 +1,319 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// ADMIN ENDPOINTS - Cookie Categories
// ========================================
// AdminGetCookieCategories returns all cookie categories
func (h *Handler) AdminGetCookieCategories(c *gin.Context) {
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
is_mandatory, sort_order, is_active, created_at, updated_at
FROM cookie_categories
ORDER BY sort_order ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
return
}
defer rows.Close()
var categories []models.CookieCategory
for rows.Next() {
var cat models.CookieCategory
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder,
&cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil {
continue
}
categories = append(categories, cat)
}
c.JSON(http.StatusOK, gin.H{"categories": categories})
}
// AdminCreateCookieCategory creates a new cookie category
func (h *Handler) AdminCreateCookieCategory(c *gin.Context) {
var req models.CreateCookieCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
var catID uuid.UUID
err := h.db.Pool.QueryRow(ctx, `
INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Cookie category created successfully",
"id": catID,
})
}
// AdminUpdateCookieCategory updates a cookie category
func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) {
catID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
}
var req struct {
DisplayNameDE *string `json:"display_name_de"`
DisplayNameEN *string `json:"display_name_en"`
DescriptionDE *string `json:"description_de"`
DescriptionEN *string `json:"description_en"`
IsMandatory *bool `json:"is_mandatory"`
SortOrder *int `json:"sort_order"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
result, err := h.db.Pool.Exec(ctx, `
UPDATE cookie_categories
SET display_name_de = COALESCE($2, display_name_de),
display_name_en = COALESCE($3, display_name_en),
description_de = COALESCE($4, description_de),
description_en = COALESCE($5, description_en),
is_mandatory = COALESCE($6, is_mandatory),
sort_order = COALESCE($7, sort_order),
is_active = COALESCE($8, is_active),
updated_at = NOW()
WHERE id = $1
`, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN,
req.IsMandatory, req.SortOrder, req.IsActive)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"})
}
// AdminDeleteCookieCategory soft-deletes a cookie category
func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) {
catID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
}
ctx := context.Background()
result, err := h.db.Pool.Exec(ctx, `
UPDATE cookie_categories
SET is_active = false, updated_at = NOW()
WHERE id = $1
`, catID)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"})
}
// ========================================
// ADMIN ENDPOINTS - Statistics & Audit
// ========================================
// GetConsentStats returns consent statistics
func (h *Handler) GetConsentStats(c *gin.Context) {
ctx := context.Background()
docType := c.Query("document_type")
var stats models.ConsentStats
// Total users
h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
// Consented users (with active consent)
query := `
SELECT COUNT(DISTINCT uc.user_id)
FROM user_consents uc
JOIN document_versions dv ON uc.document_version_id = dv.id
JOIN legal_documents ld ON dv.document_id = ld.id
WHERE uc.consented = true AND uc.withdrawn_at IS NULL
`
if docType != "" {
query += ` AND ld.type = $1`
h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers)
} else {
h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers)
}
// Calculate consent rate
if stats.TotalUsers > 0 {
stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100
}
// Recent consents (last 7 days)
h.db.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM user_consents
WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days'
`).Scan(&stats.RecentConsents)
// Recent withdrawals
h.db.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM user_consents
WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days'
`).Scan(&stats.RecentWithdrawals)
c.JSON(http.StatusOK, stats)
}
// GetCookieStats returns cookie consent statistics
func (h *Handler) GetCookieStats(c *gin.Context) {
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT cat.name,
COUNT(DISTINCT u.id) as total_users,
COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users
FROM cookie_categories cat
CROSS JOIN users u
LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id
WHERE cat.is_active = true
GROUP BY cat.id, cat.name
ORDER BY cat.sort_order
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"})
return
}
defer rows.Close()
var stats []models.CookieStats
for rows.Next() {
var s models.CookieStats
if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil {
continue
}
if s.TotalUsers > 0 {
s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100
}
stats = append(stats, s)
}
c.JSON(http.StatusOK, gin.H{"cookie_stats": stats})
}
// GetAuditLog returns audit log entries
func (h *Handler) GetAuditLog(c *gin.Context) {
ctx := context.Background()
// Pagination
limit := 50
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Filters
userIDFilter := c.Query("user_id")
actionFilter := c.Query("action")
query := `
SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details,
al.ip_address, al.user_agent, al.created_at, u.email
FROM consent_audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE 1=1
`
args := []interface{}{}
argCount := 0
if userIDFilter != "" {
argCount++
query += fmt.Sprintf(" AND al.user_id = $%d", argCount)
args = append(args, userIDFilter)
}
if actionFilter != "" {
argCount++
query += fmt.Sprintf(" AND al.action = $%d", argCount)
args = append(args, actionFilter)
}
query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2)
args = append(args, limit, offset)
rows, err := h.db.Pool.Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"})
return
}
defer rows.Close()
var logs []map[string]interface{}
for rows.Next() {
var (
id uuid.UUID
userIDPtr *uuid.UUID
action string
entityType *string
entityID *uuid.UUID
details *string
ipAddress *string
userAgent *string
createdAt time.Time
email *string
)
if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details,
&ipAddress, &userAgent, &createdAt, &email); err != nil {
continue
}
logs = append(logs, map[string]interface{}{
"id": id,
"user_id": userIDPtr,
"user_email": email,
"action": action,
"entity_type": entityType,
"entity_id": entityID,
"details": details,
"ip_address": ipAddress,
"user_agent": userAgent,
"created_at": createdAt,
})
}
c.JSON(http.StatusOK, gin.H{"audit_log": logs})
}

View File

@@ -0,0 +1,265 @@
package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GetSiteConfig gibt die Konfiguration für eine Site zurück
// GET /api/v1/banner/config/:siteId
func (h *Handler) GetSiteConfig(c *gin.Context) {
siteID := c.Param("siteId")
// Standard-Kategorien (aus Datenbank oder Default)
categories := []CategoryConfig{
{
ID: "essential",
Name: map[string]string{
"de": "Essentiell",
"en": "Essential",
},
Description: map[string]string{
"de": "Notwendig für die Grundfunktionen der Website.",
"en": "Required for basic website functionality.",
},
Required: true,
Vendors: []VendorConfig{},
},
{
ID: "functional",
Name: map[string]string{
"de": "Funktional",
"en": "Functional",
},
Description: map[string]string{
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
"en": "Enables personalization and comfort features.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "analytics",
Name: map[string]string{
"de": "Statistik",
"en": "Analytics",
},
Description: map[string]string{
"de": "Hilft uns, die Website zu verbessern.",
"en": "Helps us improve the website.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "marketing",
Name: map[string]string{
"de": "Marketing",
"en": "Marketing",
},
Description: map[string]string{
"de": "Ermöglicht personalisierte Werbung.",
"en": "Enables personalized advertising.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "social",
Name: map[string]string{
"de": "Soziale Medien",
"en": "Social Media",
},
Description: map[string]string{
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
"en": "Enables content from social networks.",
},
Required: false,
Vendors: []VendorConfig{},
},
}
config := SiteConfig{
SiteID: siteID,
SiteName: "BreakPilot",
Categories: categories,
UI: UIConfig{
Theme: "auto",
Position: "bottom",
},
Legal: LegalConfig{
PrivacyPolicyURL: "/datenschutz",
ImprintURL: "/impressum",
},
}
c.JSON(http.StatusOK, config)
}
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
// GET /api/v1/banner/consent/export?userId=xxx
func (h *Handler) ExportBannerConsent(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "missing_user_id",
"message": "userId parameter is required",
})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, site_id, device_fingerprint, categories, vendors,
version, created_at, updated_at, revoked_at
FROM banner_consents
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "export_failed",
"message": "Failed to export consent data",
})
return
}
defer rows.Close()
var consents []map[string]interface{}
for rows.Next() {
var id, siteID, deviceFingerprint, version string
var categoriesJSON, vendorsJSON []byte
var createdAt, updatedAt time.Time
var revokedAt *time.Time
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
&version, &createdAt, &updatedAt, &revokedAt)
var categories, vendors map[string]bool
json.Unmarshal(categoriesJSON, &categories)
json.Unmarshal(vendorsJSON, &vendors)
consent := map[string]interface{}{
"consentId": id,
"siteId": siteID,
"consent": map[string]interface{}{
"categories": categories,
"vendors": vendors,
},
"createdAt": createdAt.UTC().Format(time.RFC3339),
"revokedAt": nil,
}
if revokedAt != nil {
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
}
consents = append(consents, consent)
}
c.JSON(http.StatusOK, gin.H{
"userId": userID,
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"consents": consents,
})
}
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
// GET /api/v1/banner/admin/stats/:siteId
func (h *Handler) GetBannerStats(c *gin.Context) {
siteID := c.Param("siteId")
ctx := context.Background()
// Gesamtanzahl Consents
var totalConsents int
h.db.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM banner_consents
WHERE site_id = $1 AND revoked_at IS NULL
`, siteID).Scan(&totalConsents)
// Consent-Rate pro Kategorie
categoryStats := make(map[string]map[string]interface{})
rows, _ := h.db.Pool.Query(ctx, `
SELECT
key as category,
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
COUNT(*) as total
FROM banner_consents,
jsonb_each(categories::jsonb)
WHERE site_id = $1 AND revoked_at IS NULL
GROUP BY key
`, siteID)
if rows != nil {
defer rows.Close()
for rows.Next() {
var category string
var accepted, total int
rows.Scan(&category, &accepted, &total)
rate := float64(0)
if total > 0 {
rate = float64(accepted) / float64(total)
}
categoryStats[category] = map[string]interface{}{
"accepted": accepted,
"rate": rate,
}
}
}
c.JSON(http.StatusOK, gin.H{
"siteId": siteID,
"period": gin.H{
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
"to": time.Now().Format("2006-01-02"),
},
"totalConsents": totalConsents,
"consentByCategory": categoryStats,
})
}
// ========================================
// Helper Functions
// ========================================
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
func anonymizeIP(ip string) string {
// IPv4: Letztes Oktett auf 0
parts := strings.Split(ip, ".")
if len(parts) == 4 {
parts[3] = "0"
anonymized := strings.Join(parts, ".")
hash := sha256.Sum256([]byte(anonymized))
return hex.EncodeToString(hash[:])[:16]
}
// IPv6: Hash
hash := sha256.Sum256([]byte(ip))
return hex.EncodeToString(hash[:])[:16]
}
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
details, _ := json.Marshal(req)
h.db.Pool.Exec(ctx, `
INSERT INTO banner_consent_audit_log (
id, consent_id, action, details, ip_hash, created_at
) VALUES ($1, $2, $3, $4, $5, NOW())
`, uuid.New().String(), consentID, action, string(details), ipHash)
}

View File

@@ -2,11 +2,8 @@ package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -308,254 +305,3 @@ func (h *Handler) RevokeBannerConsent(c *gin.Context) {
"revokedAt": time.Now().UTC().Format(time.RFC3339),
})
}
// GetSiteConfig gibt die Konfiguration für eine Site zurück
// GET /api/v1/banner/config/:siteId
func (h *Handler) GetSiteConfig(c *gin.Context) {
siteID := c.Param("siteId")
// Standard-Kategorien (aus Datenbank oder Default)
categories := []CategoryConfig{
{
ID: "essential",
Name: map[string]string{
"de": "Essentiell",
"en": "Essential",
},
Description: map[string]string{
"de": "Notwendig für die Grundfunktionen der Website.",
"en": "Required for basic website functionality.",
},
Required: true,
Vendors: []VendorConfig{},
},
{
ID: "functional",
Name: map[string]string{
"de": "Funktional",
"en": "Functional",
},
Description: map[string]string{
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
"en": "Enables personalization and comfort features.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "analytics",
Name: map[string]string{
"de": "Statistik",
"en": "Analytics",
},
Description: map[string]string{
"de": "Hilft uns, die Website zu verbessern.",
"en": "Helps us improve the website.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "marketing",
Name: map[string]string{
"de": "Marketing",
"en": "Marketing",
},
Description: map[string]string{
"de": "Ermöglicht personalisierte Werbung.",
"en": "Enables personalized advertising.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "social",
Name: map[string]string{
"de": "Soziale Medien",
"en": "Social Media",
},
Description: map[string]string{
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
"en": "Enables content from social networks.",
},
Required: false,
Vendors: []VendorConfig{},
},
}
config := SiteConfig{
SiteID: siteID,
SiteName: "BreakPilot",
Categories: categories,
UI: UIConfig{
Theme: "auto",
Position: "bottom",
},
Legal: LegalConfig{
PrivacyPolicyURL: "/datenschutz",
ImprintURL: "/impressum",
},
}
c.JSON(http.StatusOK, config)
}
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
// GET /api/v1/banner/consent/export?userId=xxx
func (h *Handler) ExportBannerConsent(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "missing_user_id",
"message": "userId parameter is required",
})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, site_id, device_fingerprint, categories, vendors,
version, created_at, updated_at, revoked_at
FROM banner_consents
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "export_failed",
"message": "Failed to export consent data",
})
return
}
defer rows.Close()
var consents []map[string]interface{}
for rows.Next() {
var id, siteID, deviceFingerprint, version string
var categoriesJSON, vendorsJSON []byte
var createdAt, updatedAt time.Time
var revokedAt *time.Time
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
&version, &createdAt, &updatedAt, &revokedAt)
var categories, vendors map[string]bool
json.Unmarshal(categoriesJSON, &categories)
json.Unmarshal(vendorsJSON, &vendors)
consent := map[string]interface{}{
"consentId": id,
"siteId": siteID,
"consent": map[string]interface{}{
"categories": categories,
"vendors": vendors,
},
"createdAt": createdAt.UTC().Format(time.RFC3339),
"revokedAt": nil,
}
if revokedAt != nil {
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
}
consents = append(consents, consent)
}
c.JSON(http.StatusOK, gin.H{
"userId": userID,
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"consents": consents,
})
}
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
// GET /api/v1/banner/admin/stats/:siteId
func (h *Handler) GetBannerStats(c *gin.Context) {
siteID := c.Param("siteId")
ctx := context.Background()
// Gesamtanzahl Consents
var totalConsents int
h.db.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM banner_consents
WHERE site_id = $1 AND revoked_at IS NULL
`, siteID).Scan(&totalConsents)
// Consent-Rate pro Kategorie
categoryStats := make(map[string]map[string]interface{})
rows, _ := h.db.Pool.Query(ctx, `
SELECT
key as category,
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
COUNT(*) as total
FROM banner_consents,
jsonb_each(categories::jsonb)
WHERE site_id = $1 AND revoked_at IS NULL
GROUP BY key
`, siteID)
if rows != nil {
defer rows.Close()
for rows.Next() {
var category string
var accepted, total int
rows.Scan(&category, &accepted, &total)
rate := float64(0)
if total > 0 {
rate = float64(accepted) / float64(total)
}
categoryStats[category] = map[string]interface{}{
"accepted": accepted,
"rate": rate,
}
}
}
c.JSON(http.StatusOK, gin.H{
"siteId": siteID,
"period": gin.H{
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
"to": time.Now().Format("2006-01-02"),
},
"totalConsents": totalConsents,
"consentByCategory": categoryStats,
})
}
// ========================================
// Helper Functions
// ========================================
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
func anonymizeIP(ip string) string {
// IPv4: Letztes Oktett auf 0
parts := strings.Split(ip, ".")
if len(parts) == 4 {
parts[3] = "0"
anonymized := strings.Join(parts, ".")
hash := sha256.Sum256([]byte(anonymized))
return hex.EncodeToString(hash[:])[:16]
}
// IPv6: Hash
hash := sha256.Sum256([]byte(ip))
return hex.EncodeToString(hash[:])[:16]
}
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
details, _ := json.Marshal(req)
h.db.Pool.Exec(ctx, `
INSERT INTO banner_consent_audit_log (
id, consent_id, action, details, ip_hash, created_at
) VALUES ($1, $2, $3, $4, $5, NOW())
`, uuid.New().String(), consentID, action, string(details), ipHash)
}

View File

@@ -273,239 +273,3 @@ func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) {
"user_id": resp.UserID,
})
}
// ========================================
// Jitsi Video Conference Endpoints
// ========================================
// CreateMeetingRequest for creating Jitsi meetings
type CreateMeetingRequest struct {
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
Title string `json:"title,omitempty"`
DisplayName string `json:"display_name"`
Email string `json:"email,omitempty"`
Duration int `json:"duration,omitempty"` // minutes
ClassName string `json:"class_name,omitempty"`
ParentName string `json:"parent_name,omitempty"`
StudentName string `json:"student_name,omitempty"`
Subject string `json:"subject,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
}
// CreateMeeting creates a new Jitsi meeting
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
var req CreateMeetingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var link *jitsi.MeetingLink
var err error
switch req.Type {
case "quick":
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
case "training":
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
case "parent_teacher":
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
case "class":
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"room_name": link.RoomName,
"url": link.URL,
"join_url": link.JoinURL,
"moderator_url": link.ModeratorURL,
"password": link.Password,
"expires_at": link.ExpiresAt,
})
}
// GetEmbedURLRequest for embedding Jitsi
type GetEmbedURLRequest struct {
RoomName string `json:"room_name" binding:"required"`
DisplayName string `json:"display_name"`
AudioMuted bool `json:"audio_muted"`
VideoMuted bool `json:"video_muted"`
}
// GetEmbedURL returns an embeddable Jitsi URL
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
var req GetEmbedURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config := &jitsi.MeetingConfig{
StartWithAudioMuted: req.AudioMuted,
StartWithVideoMuted: req.VideoMuted,
DisableDeepLinking: true,
}
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
c.JSON(http.StatusOK, gin.H{
"embed_url": embedURL,
"iframe_code": iframeCode,
})
}
// GetJitsiInfo returns Jitsi server information
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
info := h.jitsiService.GetServerInfo()
c.JSON(http.StatusOK, info)
}
// ========================================
// Admin Statistics Endpoints (for Admin Panel)
// ========================================
// CommunicationStats holds communication service statistics
type CommunicationStats struct {
Matrix MatrixStats `json:"matrix"`
Jitsi JitsiStats `json:"jitsi"`
}
// MatrixStats holds Matrix-specific statistics
type MatrixStats struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
ServerName string `json:"server_name"`
// TODO: Add real stats from Matrix Synapse Admin API
TotalUsers int `json:"total_users"`
TotalRooms int `json:"total_rooms"`
ActiveToday int `json:"active_today"`
MessagesToday int `json:"messages_today"`
}
// JitsiStats holds Jitsi-specific statistics
type JitsiStats struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
BaseURL string `json:"base_url"`
AuthEnabled bool `json:"auth_enabled"`
// TODO: Add real stats from Jitsi SRTP API or Jicofo
ActiveMeetings int `json:"active_meetings"`
TotalParticipants int `json:"total_participants"`
MeetingsToday int `json:"meetings_today"`
AvgDurationMin int `json:"avg_duration_min"`
}
// GetAdminStats returns admin statistics for Matrix and Jitsi
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
ctx := c.Request.Context()
stats := CommunicationStats{}
// Matrix Stats
if h.matrixService != nil {
matrixErr := h.matrixService.HealthCheck(ctx)
stats.Matrix = MatrixStats{
Enabled: true,
Healthy: matrixErr == nil,
ServerName: h.matrixService.GetServerName(),
// Placeholder stats - in production these would come from Synapse Admin API
TotalUsers: 0,
TotalRooms: 0,
ActiveToday: 0,
MessagesToday: 0,
}
} else {
stats.Matrix = MatrixStats{Enabled: false}
}
// Jitsi Stats
if h.jitsiService != nil {
jitsiErr := h.jitsiService.HealthCheck(ctx)
serverInfo := h.jitsiService.GetServerInfo()
stats.Jitsi = JitsiStats{
Enabled: true,
Healthy: jitsiErr == nil,
BaseURL: serverInfo["base_url"],
AuthEnabled: serverInfo["auth_enabled"] == "true",
// Placeholder stats - in production these would come from Jicofo/JVB stats
ActiveMeetings: 0,
TotalParticipants: 0,
MeetingsToday: 0,
AvgDurationMin: 0,
}
} else {
stats.Jitsi = JitsiStats{Enabled: false}
}
c.JSON(http.StatusOK, stats)
}
// ========================================
// Helper Functions
// ========================================
func errToString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// RegisterRoutes registers all communication routes
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
comm := router.Group("/communication")
{
// Public health check
comm.GET("/status", h.GetCommunicationStatus)
// Protected routes
protected := comm.Group("")
protected.Use(authMiddleware)
{
// Matrix
protected.POST("/rooms", h.CreateRoom)
protected.POST("/rooms/invite", h.InviteUser)
protected.POST("/messages", h.SendMessage)
protected.POST("/notifications", h.SendNotification)
// Jitsi
protected.POST("/meetings", h.CreateMeeting)
protected.POST("/meetings/embed", h.GetEmbedURL)
protected.GET("/jitsi/info", h.GetJitsiInfo)
}
// Admin routes (for Matrix user registration and stats)
admin := comm.Group("/admin")
admin.Use(authMiddleware)
// TODO: Add AdminOnly middleware
{
admin.POST("/matrix/users", h.RegisterMatrixUser)
admin.GET("/stats", h.GetAdminStats)
}
}
}

View File

@@ -0,0 +1,245 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/services/jitsi"
"github.com/gin-gonic/gin"
)
// ========================================
// Jitsi Video Conference Endpoints
// ========================================
// CreateMeetingRequest for creating Jitsi meetings
type CreateMeetingRequest struct {
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
Title string `json:"title,omitempty"`
DisplayName string `json:"display_name"`
Email string `json:"email,omitempty"`
Duration int `json:"duration,omitempty"` // minutes
ClassName string `json:"class_name,omitempty"`
ParentName string `json:"parent_name,omitempty"`
StudentName string `json:"student_name,omitempty"`
Subject string `json:"subject,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
}
// CreateMeeting creates a new Jitsi meeting
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
var req CreateMeetingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var link *jitsi.MeetingLink
var err error
switch req.Type {
case "quick":
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
case "training":
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
case "parent_teacher":
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
case "class":
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"room_name": link.RoomName,
"url": link.URL,
"join_url": link.JoinURL,
"moderator_url": link.ModeratorURL,
"password": link.Password,
"expires_at": link.ExpiresAt,
})
}
// GetEmbedURLRequest for embedding Jitsi
type GetEmbedURLRequest struct {
RoomName string `json:"room_name" binding:"required"`
DisplayName string `json:"display_name"`
AudioMuted bool `json:"audio_muted"`
VideoMuted bool `json:"video_muted"`
}
// GetEmbedURL returns an embeddable Jitsi URL
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
var req GetEmbedURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config := &jitsi.MeetingConfig{
StartWithAudioMuted: req.AudioMuted,
StartWithVideoMuted: req.VideoMuted,
DisableDeepLinking: true,
}
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
c.JSON(http.StatusOK, gin.H{
"embed_url": embedURL,
"iframe_code": iframeCode,
})
}
// GetJitsiInfo returns Jitsi server information
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
info := h.jitsiService.GetServerInfo()
c.JSON(http.StatusOK, info)
}
// ========================================
// Admin Statistics Endpoints (for Admin Panel)
// ========================================
// CommunicationStats holds communication service statistics
type CommunicationStats struct {
Matrix MatrixStats `json:"matrix"`
Jitsi JitsiStats `json:"jitsi"`
}
// MatrixStats holds Matrix-specific statistics
type MatrixStats struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
ServerName string `json:"server_name"`
// TODO: Add real stats from Matrix Synapse Admin API
TotalUsers int `json:"total_users"`
TotalRooms int `json:"total_rooms"`
ActiveToday int `json:"active_today"`
MessagesToday int `json:"messages_today"`
}
// JitsiStats holds Jitsi-specific statistics
type JitsiStats struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
BaseURL string `json:"base_url"`
AuthEnabled bool `json:"auth_enabled"`
// TODO: Add real stats from Jitsi SRTP API or Jicofo
ActiveMeetings int `json:"active_meetings"`
TotalParticipants int `json:"total_participants"`
MeetingsToday int `json:"meetings_today"`
AvgDurationMin int `json:"avg_duration_min"`
}
// GetAdminStats returns admin statistics for Matrix and Jitsi
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
ctx := c.Request.Context()
stats := CommunicationStats{}
// Matrix Stats
if h.matrixService != nil {
matrixErr := h.matrixService.HealthCheck(ctx)
stats.Matrix = MatrixStats{
Enabled: true,
Healthy: matrixErr == nil,
ServerName: h.matrixService.GetServerName(),
// Placeholder stats - in production these would come from Synapse Admin API
TotalUsers: 0,
TotalRooms: 0,
ActiveToday: 0,
MessagesToday: 0,
}
} else {
stats.Matrix = MatrixStats{Enabled: false}
}
// Jitsi Stats
if h.jitsiService != nil {
jitsiErr := h.jitsiService.HealthCheck(ctx)
serverInfo := h.jitsiService.GetServerInfo()
stats.Jitsi = JitsiStats{
Enabled: true,
Healthy: jitsiErr == nil,
BaseURL: serverInfo["base_url"],
AuthEnabled: serverInfo["auth_enabled"] == "true",
// Placeholder stats - in production these would come from Jicofo/JVB stats
ActiveMeetings: 0,
TotalParticipants: 0,
MeetingsToday: 0,
AvgDurationMin: 0,
}
} else {
stats.Jitsi = JitsiStats{Enabled: false}
}
c.JSON(http.StatusOK, stats)
}
// ========================================
// Helper Functions
// ========================================
func errToString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// RegisterRoutes registers all communication routes
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
comm := router.Group("/communication")
{
// Public health check
comm.GET("/status", h.GetCommunicationStatus)
// Protected routes
protected := comm.Group("")
protected.Use(authMiddleware)
{
// Matrix
protected.POST("/rooms", h.CreateRoom)
protected.POST("/rooms/invite", h.InviteUser)
protected.POST("/messages", h.SendMessage)
protected.POST("/notifications", h.SendNotification)
// Jitsi
protected.POST("/meetings", h.CreateMeeting)
protected.POST("/meetings/embed", h.GetEmbedURL)
protected.GET("/jitsi/info", h.GetJitsiInfo)
}
// Admin routes (for Matrix user registration and stats)
admin := comm.Group("/admin")
admin.Use(authMiddleware)
// TODO: Add AdminOnly middleware
{
admin.POST("/matrix/users", h.RegisterMatrixUser)
admin.GET("/stats", h.GetAdminStats)
}
}
}

View File

@@ -0,0 +1,244 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// PUBLIC ENDPOINTS - Consent
// ========================================
// CreateConsent creates a new user consent
func (h *Handler) CreateConsent(c *gin.Context) {
var req models.CreateConsentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
versionID, err := uuid.Parse(req.VersionID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Upsert consent
var consentID uuid.UUID
err = h.db.Pool.QueryRow(ctx, `
INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, document_version_id)
DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL
RETURNING id
`, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"})
return
}
// Log to audit trail
h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent)
c.JSON(http.StatusCreated, gin.H{
"message": "Consent saved successfully",
"consent_id": consentID,
})
}
// GetMyConsents returns all consents for the current user
func (h *Handler) GetMyConsents(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at,
ld.id, ld.type, ld.name, ld.is_mandatory,
dv.id, dv.version, dv.language, dv.title
FROM user_consents uc
JOIN document_versions dv ON uc.document_version_id = dv.id
JOIN legal_documents ld ON dv.document_id = ld.id
WHERE uc.user_id = $1
ORDER BY uc.consented_at DESC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"})
return
}
defer rows.Close()
var consents []map[string]interface{}
for rows.Next() {
var (
consentID uuid.UUID
consented bool
consentedAt time.Time
withdrawnAt *time.Time
docID uuid.UUID
docType string
docName string
isMandatory bool
versionID uuid.UUID
version string
language string
title string
)
if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt,
&docID, &docType, &docName, &isMandatory,
&versionID, &version, &language, &title); err != nil {
continue
}
consents = append(consents, map[string]interface{}{
"consent_id": consentID,
"consented": consented,
"consented_at": consentedAt,
"withdrawn_at": withdrawnAt,
"document": map[string]interface{}{
"id": docID,
"type": docType,
"name": docName,
"is_mandatory": isMandatory,
},
"version": map[string]interface{}{
"id": versionID,
"version": version,
"language": language,
"title": title,
},
})
}
c.JSON(http.StatusOK, gin.H{"consents": consents})
}
// CheckConsent checks if the user has consented to a document
func (h *Handler) CheckConsent(c *gin.Context) {
docType := c.Param("documentType")
language := c.DefaultQuery("language", "de")
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
// Get latest published version
var latestVersionID uuid.UUID
var latestVersion string
err = h.db.Pool.QueryRow(ctx, `
SELECT dv.id, dv.version
FROM document_versions dv
JOIN legal_documents ld ON dv.document_id = ld.id
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
ORDER BY dv.published_at DESC
LIMIT 1
`, docType, language).Scan(&latestVersionID, &latestVersion)
if err != nil {
c.JSON(http.StatusOK, models.ConsentCheckResponse{
HasConsent: false,
NeedsUpdate: false,
})
return
}
// Check if user has consented to this version
var consentedVersionID uuid.UUID
var consentedVersion string
var consentedAt time.Time
err = h.db.Pool.QueryRow(ctx, `
SELECT dv.id, dv.version, uc.consented_at
FROM user_consents uc
JOIN document_versions dv ON uc.document_version_id = dv.id
JOIN legal_documents ld ON dv.document_id = ld.id
WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL
ORDER BY uc.consented_at DESC
LIMIT 1
`, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt)
if err != nil {
// No consent found
latestIDStr := latestVersionID.String()
c.JSON(http.StatusOK, models.ConsentCheckResponse{
HasConsent: false,
CurrentVersionID: &latestIDStr,
NeedsUpdate: true,
})
return
}
// Check if consent is for latest version
needsUpdate := consentedVersionID != latestVersionID
latestIDStr := latestVersionID.String()
consentedVerStr := consentedVersion
c.JSON(http.StatusOK, models.ConsentCheckResponse{
HasConsent: true,
CurrentVersionID: &latestIDStr,
ConsentedVersion: &consentedVerStr,
NeedsUpdate: needsUpdate,
ConsentedAt: &consentedAt,
})
}
// WithdrawConsent withdraws a consent
func (h *Handler) WithdrawConsent(c *gin.Context) {
consentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"})
return
}
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Update consent
result, err := h.db.Pool.Exec(ctx, `
UPDATE user_consents
SET withdrawn_at = NOW(), consented = false
WHERE id = $1 AND user_id = $2
`, consentID, userID)
if err != nil || result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"})
return
}
// Log to audit trail
h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent)
c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"})
}

View File

@@ -0,0 +1,158 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// PUBLIC ENDPOINTS - Cookie Consent
// ========================================
// GetCookieCategories returns all active cookie categories
func (h *Handler) GetCookieCategories(c *gin.Context) {
language := c.DefaultQuery("language", "de")
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
is_mandatory, sort_order
FROM cookie_categories
WHERE is_active = true
ORDER BY sort_order ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
return
}
defer rows.Close()
var categories []map[string]interface{}
for rows.Next() {
var cat models.CookieCategory
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil {
continue
}
// Return localized data
displayName := cat.DisplayNameDE
description := cat.DescriptionDE
if language == "en" && cat.DisplayNameEN != nil {
displayName = *cat.DisplayNameEN
if cat.DescriptionEN != nil {
description = cat.DescriptionEN
}
}
categories = append(categories, map[string]interface{}{
"id": cat.ID,
"name": cat.Name,
"display_name": displayName,
"description": description,
"is_mandatory": cat.IsMandatory,
})
}
c.JSON(http.StatusOK, gin.H{"categories": categories})
}
// SetCookieConsent sets cookie preferences for a user
func (h *Handler) SetCookieConsent(c *gin.Context) {
var req models.CookieConsentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Process each category
for _, cat := range req.Categories {
categoryID, err := uuid.Parse(cat.CategoryID)
if err != nil {
continue
}
_, err = h.db.Pool.Exec(ctx, `
INSERT INTO cookie_consents (user_id, category_id, consented)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, category_id)
DO UPDATE SET consented = $3, updated_at = NOW()
`, userID, categoryID, cat.Consented)
if err != nil {
continue
}
}
// Log to audit trail
h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent)
c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"})
}
// GetMyCookieConsent returns cookie preferences for the current user
func (h *Handler) GetMyCookieConsent(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT cc.category_id, cc.consented, cc.updated_at,
cat.name, cat.display_name_de, cat.is_mandatory
FROM cookie_consents cc
JOIN cookie_categories cat ON cc.category_id = cat.id
WHERE cc.user_id = $1
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"})
return
}
defer rows.Close()
var consents []map[string]interface{}
for rows.Next() {
var (
categoryID uuid.UUID
consented bool
updatedAt time.Time
name string
displayName string
isMandatory bool
)
if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil {
continue
}
consents = append(consents, map[string]interface{}{
"category_id": categoryID,
"name": name,
"display_name": displayName,
"consented": consented,
"is_mandatory": isMandatory,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"cookie_consents": consents})
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"context"
"net/http"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
)
// ========================================
// PUBLIC ENDPOINTS - Documents
// ========================================
// GetDocuments returns all active legal documents
func (h *Handler) GetDocuments(c *gin.Context) {
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
FROM legal_documents
WHERE is_active = true
ORDER BY sort_order ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
return
}
defer rows.Close()
var documents []models.LegalDocument
for rows.Next() {
var doc models.LegalDocument
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
continue
}
documents = append(documents, doc)
}
c.JSON(http.StatusOK, gin.H{"documents": documents})
}
// GetDocumentByType returns a document by its type
func (h *Handler) GetDocumentByType(c *gin.Context) {
docType := c.Param("type")
ctx := context.Background()
var doc models.LegalDocument
err := h.db.Pool.QueryRow(ctx, `
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
FROM legal_documents
WHERE type = $1 AND is_active = true
`, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusOK, doc)
}
// GetLatestDocumentVersion returns the latest published version of a document
func (h *Handler) GetLatestDocumentVersion(c *gin.Context) {
docType := c.Param("type")
language := c.DefaultQuery("language", "de")
ctx := context.Background()
var version models.DocumentVersion
err := h.db.Pool.QueryRow(ctx, `
SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content,
dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at
FROM document_versions dv
JOIN legal_documents ld ON dv.document_id = ld.id
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
ORDER BY dv.published_at DESC
LIMIT 1
`, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language,
&version.Title, &version.Content, &version.Summary, &version.Status,
&version.PublishedAt, &version.CreatedAt, &version.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"})
return
}
c.JSON(http.StatusOK, version)
}

View File

@@ -3,8 +3,6 @@ package handlers
import (
"context"
"net/http"
"strconv"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
@@ -135,814 +133,3 @@ func (h *DSRHandler) CancelMyDSR(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"})
}
// ========================================
// ADMIN ENDPOINTS
// ========================================
// AdminListDSR returns all DSRs with filters (admin only)
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
// Parse pagination
limit := 20
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Parse filters
filters := models.DSRListFilters{}
if status := c.Query("status"); status != "" {
filters.Status = &status
}
if reqType := c.Query("request_type"); reqType != "" {
filters.RequestType = &reqType
}
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
filters.AssignedTo = &assignedTo
}
if priority := c.Query("priority"); priority != "" {
filters.Priority = &priority
}
if c.Query("overdue_only") == "true" {
filters.OverdueOnly = true
}
if search := c.Query("search"); search != "" {
filters.Search = &search
}
if from := c.Query("from_date"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
filters.FromDate = &t
}
}
if to := c.Query("to_date"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
filters.ToDate = &t
}
}
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
return
}
c.JSON(http.StatusOK, gin.H{
"requests": dsrs,
"total": total,
"limit": limit,
"offset": offset,
})
}
// AdminGetDSR returns a specific DSR (admin only)
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
return
}
c.JSON(http.StatusOK, dsr)
}
// AdminCreateDSR creates a DSR manually (admin only)
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
// Set source as admin_panel
if req.Source == "" {
req.Source = "admin_panel"
}
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Anfrage wurde erstellt",
"request_number": dsr.RequestNumber,
"dsr": dsr,
})
}
// AdminUpdateDSR updates a DSR (admin only)
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.UpdateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := c.Request.Context()
// Update status if provided
if req.Status != nil {
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// Update processing notes
if req.ProcessingNotes != nil {
h.dsrService.GetPool().Exec(ctx, `
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
`, *req.ProcessingNotes, dsrID)
}
// Update priority
if req.Priority != nil {
h.dsrService.GetPool().Exec(ctx, `
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
`, *req.Priority, dsrID)
}
// Get updated DSR
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
c.JSON(http.StatusOK, gin.H{
"message": "Anfrage wurde aktualisiert",
"dsr": dsr,
})
}
// AdminGetDSRStats returns dashboard statistics
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
return
}
c.JSON(http.StatusOK, stats)
}
// AdminVerifyIdentity verifies the identity of a requester
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.VerifyDSRIdentityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
}
// AdminAssignDSR assigns a DSR to a user
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
AssigneeID string `json:"assignee_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
assigneeID, err := uuid.Parse(req.AssigneeID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
return
}
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
}
// AdminExtendDSRDeadline extends the deadline for a DSR
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.ExtendDSRDeadlineRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
}
// AdminCompleteDSR marks a DSR as completed
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.CompleteDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
}
// AdminRejectDSR rejects a DSR
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.RejectDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
}
// AdminGetDSRHistory returns the status history for a DSR
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
return
}
c.JSON(http.StatusOK, gin.H{"history": history})
}
// AdminGetDSRCommunications returns communications for a DSR
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
return
}
c.JSON(http.StatusOK, gin.H{"communications": comms})
}
// AdminSendDSRCommunication sends a communication for a DSR
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.SendDSRCommunicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
}
// AdminUpdateDSRStatus updates the status of a DSR
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Status string `json:"status" binding:"required"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
}
// ========================================
// EXCEPTION CHECKS (Art. 17)
// ========================================
// AdminGetExceptionChecks returns exception checks for an erasure DSR
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
return
}
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
}
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
}
// AdminUpdateExceptionCheck updates a single exception check
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
checkID, err := uuid.Parse(c.Param("checkId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Applies bool `json:"applies"`
Notes *string `json:"notes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
}
// ========================================
// TEMPLATE ENDPOINTS
// ========================================
// AdminGetDSRTemplates returns all DSR templates
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
ctx := c.Request.Context()
rows, err := h.dsrService.GetPool().Query(ctx, `
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
FROM dsr_templates ORDER BY sort_order, name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []map[string]interface{}
for rows.Next() {
var id uuid.UUID
var templateType, name string
var description *string
var requestTypes []byte
var isActive bool
var sortOrder int
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
if err != nil {
continue
}
templates = append(templates, map[string]interface{}{
"id": id,
"template_type": templateType,
"name": name,
"description": description,
"request_types": string(requestTypes),
"is_active": isActive,
"sort_order": sortOrder,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// AdminGetDSRTemplateVersions returns versions for a template
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
}
ctx := c.Request.Context()
rows, err := h.dsrService.GetPool().Query(ctx, `
SELECT id, template_id, version, language, subject, body_html, body_text,
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
`, templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
return
}
defer rows.Close()
var versions []map[string]interface{}
for rows.Next() {
var id, tempID uuid.UUID
var version, language, subject, bodyHTML, bodyText, status string
var publishedAt, approvedAt *time.Time
var createdBy, approvedBy *uuid.UUID
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
if err != nil {
continue
}
versions = append(versions, map[string]interface{}{
"id": id,
"template_id": tempID,
"version": version,
"language": language,
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
"status": status,
"published_at": publishedAt,
"created_by": createdBy,
"approved_by": approvedBy,
"approved_at": approvedAt,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"versions": versions})
}
// AdminCreateDSRTemplateVersion creates a new template version
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Version string `json:"version" binding:"required"`
Language string `json:"language"`
Subject string `json:"subject" binding:"required"`
BodyHTML string `json:"body_html" binding:"required"`
BodyText string `json:"body_text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Language == "" {
req.Language = "de"
}
ctx := c.Request.Context()
var versionID uuid.UUID
err = h.dsrService.GetPool().QueryRow(ctx, `
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Version wurde erstellt",
"id": versionID,
})
}
// AdminPublishDSRTemplateVersion publishes a template version
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
versionID, err := uuid.Parse(c.Param("versionId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := c.Request.Context()
_, err = h.dsrService.GetPool().Exec(ctx, `
UPDATE dsr_template_versions
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
WHERE id = $2
`, userID, versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
}
// AdminGetPublishedDSRTemplates returns all published templates for selection
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
requestType := c.Query("request_type")
language := c.DefaultQuery("language", "de")
ctx := c.Request.Context()
query := `
SELECT t.id, t.template_type, t.name, t.description,
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
FROM dsr_templates t
JOIN dsr_template_versions v ON t.id = v.template_id
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
`
args := []interface{}{language}
if requestType != "" {
query += ` AND t.request_types @> $2::jsonb`
args = append(args, `["`+requestType+`"]`)
}
query += " ORDER BY t.sort_order, t.name"
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []map[string]interface{}
for rows.Next() {
var templateID, versionID uuid.UUID
var templateType, name, version, subject, bodyHTML, bodyText string
var description *string
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
if err != nil {
continue
}
templates = append(templates, map[string]interface{}{
"template_id": templateID,
"template_type": templateType,
"name": name,
"description": description,
"version_id": versionID,
"version": version,
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// ========================================
// DEADLINE PROCESSING
// ========================================
// ProcessDeadlines triggers deadline checking (called by scheduler)
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
err := h.dsrService.ProcessDeadlines(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
}

View File

@@ -0,0 +1,388 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// ADMIN ENDPOINTS — CRUD & Workflow
// ========================================
// AdminListDSR returns all DSRs with filters (admin only)
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
// Parse pagination
limit := 20
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Parse filters
filters := models.DSRListFilters{}
if status := c.Query("status"); status != "" {
filters.Status = &status
}
if reqType := c.Query("request_type"); reqType != "" {
filters.RequestType = &reqType
}
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
filters.AssignedTo = &assignedTo
}
if priority := c.Query("priority"); priority != "" {
filters.Priority = &priority
}
if c.Query("overdue_only") == "true" {
filters.OverdueOnly = true
}
if search := c.Query("search"); search != "" {
filters.Search = &search
}
if from := c.Query("from_date"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
filters.FromDate = &t
}
}
if to := c.Query("to_date"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
filters.ToDate = &t
}
}
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
return
}
c.JSON(http.StatusOK, gin.H{
"requests": dsrs,
"total": total,
"limit": limit,
"offset": offset,
})
}
// AdminGetDSR returns a specific DSR (admin only)
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
return
}
c.JSON(http.StatusOK, dsr)
}
// AdminCreateDSR creates a DSR manually (admin only)
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
// Set source as admin_panel
if req.Source == "" {
req.Source = "admin_panel"
}
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Anfrage wurde erstellt",
"request_number": dsr.RequestNumber,
"dsr": dsr,
})
}
// AdminUpdateDSR updates a DSR (admin only)
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.UpdateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := c.Request.Context()
// Update status if provided
if req.Status != nil {
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// Update processing notes
if req.ProcessingNotes != nil {
h.dsrService.GetPool().Exec(ctx, `
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
`, *req.ProcessingNotes, dsrID)
}
// Update priority
if req.Priority != nil {
h.dsrService.GetPool().Exec(ctx, `
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
`, *req.Priority, dsrID)
}
// Get updated DSR
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
c.JSON(http.StatusOK, gin.H{
"message": "Anfrage wurde aktualisiert",
"dsr": dsr,
})
}
// AdminGetDSRStats returns dashboard statistics
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
return
}
c.JSON(http.StatusOK, stats)
}
// AdminVerifyIdentity verifies the identity of a requester
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.VerifyDSRIdentityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
}
// AdminAssignDSR assigns a DSR to a user
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
AssigneeID string `json:"assignee_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
assigneeID, err := uuid.Parse(req.AssigneeID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
return
}
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
}
// AdminExtendDSRDeadline extends the deadline for a DSR
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.ExtendDSRDeadlineRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
}
// AdminCompleteDSR marks a DSR as completed
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.CompleteDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
}
// AdminRejectDSR rejects a DSR
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.RejectDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
}
// AdminGetDSRHistory returns the status history for a DSR
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
return
}
c.JSON(http.StatusOK, gin.H{"history": history})
}

View File

@@ -0,0 +1,195 @@
package handlers
import (
"net/http"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// ADMIN — Communications & Status
// ========================================
// AdminGetDSRCommunications returns communications for a DSR
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
return
}
c.JSON(http.StatusOK, gin.H{"communications": comms})
}
// AdminSendDSRCommunication sends a communication for a DSR
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.SendDSRCommunicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
}
// AdminUpdateDSRStatus updates the status of a DSR
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Status string `json:"status" binding:"required"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
}
// ========================================
// EXCEPTION CHECKS (Art. 17)
// ========================================
// AdminGetExceptionChecks returns exception checks for an erasure DSR
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
return
}
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
}
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
}
// AdminUpdateExceptionCheck updates a single exception check
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
checkID, err := uuid.Parse(c.Param("checkId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Applies bool `json:"applies"`
Notes *string `json:"notes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
}
// ========================================
// DEADLINE PROCESSING
// ========================================
// ProcessDeadlines triggers deadline checking (called by scheduler)
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
err := h.dsrService.ProcessDeadlines(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
}

View File

@@ -0,0 +1,264 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// TEMPLATE ENDPOINTS
// ========================================
// AdminGetDSRTemplates returns all DSR templates
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
ctx := c.Request.Context()
rows, err := h.dsrService.GetPool().Query(ctx, `
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
FROM dsr_templates ORDER BY sort_order, name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []map[string]interface{}
for rows.Next() {
var id uuid.UUID
var templateType, name string
var description *string
var requestTypes []byte
var isActive bool
var sortOrder int
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
if err != nil {
continue
}
templates = append(templates, map[string]interface{}{
"id": id,
"template_type": templateType,
"name": name,
"description": description,
"request_types": string(requestTypes),
"is_active": isActive,
"sort_order": sortOrder,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// AdminGetDSRTemplateVersions returns versions for a template
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
}
ctx := c.Request.Context()
rows, err := h.dsrService.GetPool().Query(ctx, `
SELECT id, template_id, version, language, subject, body_html, body_text,
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
`, templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
return
}
defer rows.Close()
var versions []map[string]interface{}
for rows.Next() {
var id, tempID uuid.UUID
var version, language, subject, bodyHTML, bodyText, status string
var publishedAt, approvedAt *time.Time
var createdBy, approvedBy *uuid.UUID
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
if err != nil {
continue
}
versions = append(versions, map[string]interface{}{
"id": id,
"template_id": tempID,
"version": version,
"language": language,
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
"status": status,
"published_at": publishedAt,
"created_by": createdBy,
"approved_by": approvedBy,
"approved_at": approvedAt,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"versions": versions})
}
// AdminCreateDSRTemplateVersion creates a new template version
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Version string `json:"version" binding:"required"`
Language string `json:"language"`
Subject string `json:"subject" binding:"required"`
BodyHTML string `json:"body_html" binding:"required"`
BodyText string `json:"body_text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Language == "" {
req.Language = "de"
}
ctx := c.Request.Context()
var versionID uuid.UUID
err = h.dsrService.GetPool().QueryRow(ctx, `
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Version wurde erstellt",
"id": versionID,
})
}
// AdminPublishDSRTemplateVersion publishes a template version
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
versionID, err := uuid.Parse(c.Param("versionId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := c.Request.Context()
_, err = h.dsrService.GetPool().Exec(ctx, `
UPDATE dsr_template_versions
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
WHERE id = $2
`, userID, versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
}
// AdminGetPublishedDSRTemplates returns all published templates for selection
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
requestType := c.Query("request_type")
language := c.DefaultQuery("language", "de")
ctx := c.Request.Context()
query := `
SELECT t.id, t.template_type, t.name, t.description,
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
FROM dsr_templates t
JOIN dsr_template_versions v ON t.id = v.template_id
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
`
args := []interface{}{language}
if requestType != "" {
query += ` AND t.request_types @> $2::jsonb`
args = append(args, `["`+requestType+`"]`)
}
query += " ORDER BY t.sort_order, t.name"
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []map[string]interface{}
for rows.Next() {
var templateID, versionID uuid.UUID
var templateType, name, version, subject, bodyHTML, bodyText string
var description *string
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
if err != nil {
continue
}
templates = append(templates, map[string]interface{}{
"template_id": templateID,
"template_type": templateType,
"name": name,
"description": description,
"version_id": versionID,
"version": version,
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/consent-service/internal/models"
@@ -261,268 +260,3 @@ func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "version rejected"})
}
// PublishVersion publishes an approved version
// POST /api/v1/admin/email-template-versions/:id/publish
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
role, exists := c.Get("user_role")
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version published"})
}
// GetApprovals returns approval history for a version
// GET /api/v1/admin/email-template-versions/:id/approvals
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
}
// PreviewVersion renders a preview of an email template version
// POST /api/v1/admin/email-template-versions/:id/preview
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req struct {
Variables map[string]string `json:"variables"`
}
c.ShouldBindJSON(&req)
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
// Use default test values if not provided
if req.Variables == nil {
req.Variables = map[string]string{
"user_name": "Max Mustermann",
"user_email": "max@example.com",
"login_url": "https://breakpilot.app/login",
"support_email": "support@breakpilot.app",
"verification_url": "https://breakpilot.app/verify?token=abc123",
"verification_code": "123456",
"expires_in": "24 Stunden",
"reset_url": "https://breakpilot.app/reset?token=xyz789",
"reset_code": "RESET123",
"ip_address": "192.168.1.1",
"device_info": "Chrome auf Windows 11",
"changed_at": time.Now().Format("02.01.2006 15:04"),
"enabled_at": time.Now().Format("02.01.2006 15:04"),
"disabled_at": time.Now().Format("02.01.2006 15:04"),
"support_url": "https://breakpilot.app/support",
"security_url": "https://breakpilot.app/account/security",
"login_time": time.Now().Format("02.01.2006 15:04"),
"location": "Berlin, Deutschland",
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
"activity_time": time.Now().Format("02.01.2006 15:04"),
"locked_at": time.Now().Format("02.01.2006 15:04"),
"reason": "Zu viele fehlgeschlagene Login-Versuche",
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
"requested_at": time.Now().Format("02.01.2006"),
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
"deleted_at": time.Now().Format("02.01.2006"),
"feedback_url": "https://breakpilot.app/feedback",
"download_url": "https://breakpilot.app/export/download?token=export123",
"file_size": "2.3 MB",
"old_email": "alt@example.com",
"new_email": "neu@example.com",
"document_name": "Datenschutzerklärung",
"document_type": "privacy",
"version": "2.0.0",
"consent_url": "https://breakpilot.app/consent",
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
"days_left": "7",
"hours_left": "24 Stunden",
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
"suspended_at": time.Now().Format("02.01.2006 15:04"),
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
}
}
preview, err := h.service.RenderTemplate(version, req.Variables)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, preview)
}
// SendTestEmail sends a test email
// POST /api/v1/admin/email-template-versions/:id/send-test
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req models.SendTestEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.VersionID = idStr
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
// Get template to find type
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
// Send test email
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
}
// GetSettings returns global email settings
// GET /api/v1/admin/email-templates/settings
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
settings, err := h.service.GetSettings(c.Request.Context())
if err != nil {
// Return default settings if none exist
c.JSON(http.StatusOK, gin.H{
"company_name": "BreakPilot",
"sender_name": "BreakPilot",
"sender_email": "noreply@breakpilot.app",
"primary_color": "#2563eb",
"secondary_color": "#64748b",
})
return
}
c.JSON(http.StatusOK, settings)
}
// UpdateSettings updates global email settings
// PUT /api/v1/admin/email-templates/settings
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
var req models.UpdateEmailTemplateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
}
// GetEmailStats returns email statistics
// GET /api/v1/admin/email-templates/stats
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
stats, err := h.service.GetEmailStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetSendLogs returns email send logs
// GET /api/v1/admin/email-templates/logs
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "50")
offsetStr := c.DefaultQuery("offset", "0")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
if limit > 100 {
limit = 100
}
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
}
// GetDefaultContent returns default template content for a type
// GET /api/v1/admin/email-templates/default/:type
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
templateType := c.Param("type")
language := c.DefaultQuery("language", "de")
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
c.JSON(http.StatusOK, gin.H{
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
})
}
// InitializeTemplates initializes default email templates
// POST /api/v1/admin/email-templates/initialize
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists || (role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
}

View File

@@ -0,0 +1,276 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// PublishVersion publishes an approved version
// POST /api/v1/admin/email-template-versions/:id/publish
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
role, exists := c.Get("user_role")
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version published"})
}
// GetApprovals returns approval history for a version
// GET /api/v1/admin/email-template-versions/:id/approvals
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
}
// PreviewVersion renders a preview of an email template version
// POST /api/v1/admin/email-template-versions/:id/preview
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req struct {
Variables map[string]string `json:"variables"`
}
c.ShouldBindJSON(&req)
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
// Use default test values if not provided
if req.Variables == nil {
req.Variables = map[string]string{
"user_name": "Max Mustermann",
"user_email": "max@example.com",
"login_url": "https://breakpilot.app/login",
"support_email": "support@breakpilot.app",
"verification_url": "https://breakpilot.app/verify?token=abc123",
"verification_code": "123456",
"expires_in": "24 Stunden",
"reset_url": "https://breakpilot.app/reset?token=xyz789",
"reset_code": "RESET123",
"ip_address": "192.168.1.1",
"device_info": "Chrome auf Windows 11",
"changed_at": time.Now().Format("02.01.2006 15:04"),
"enabled_at": time.Now().Format("02.01.2006 15:04"),
"disabled_at": time.Now().Format("02.01.2006 15:04"),
"support_url": "https://breakpilot.app/support",
"security_url": "https://breakpilot.app/account/security",
"login_time": time.Now().Format("02.01.2006 15:04"),
"location": "Berlin, Deutschland",
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
"activity_time": time.Now().Format("02.01.2006 15:04"),
"locked_at": time.Now().Format("02.01.2006 15:04"),
"reason": "Zu viele fehlgeschlagene Login-Versuche",
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
"requested_at": time.Now().Format("02.01.2006"),
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
"deleted_at": time.Now().Format("02.01.2006"),
"feedback_url": "https://breakpilot.app/feedback",
"download_url": "https://breakpilot.app/export/download?token=export123",
"file_size": "2.3 MB",
"old_email": "alt@example.com",
"new_email": "neu@example.com",
"document_name": "Datenschutzerklärung",
"document_type": "privacy",
"version": "2.0.0",
"consent_url": "https://breakpilot.app/consent",
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
"days_left": "7",
"hours_left": "24 Stunden",
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
"suspended_at": time.Now().Format("02.01.2006 15:04"),
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
}
}
preview, err := h.service.RenderTemplate(version, req.Variables)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, preview)
}
// SendTestEmail sends a test email
// POST /api/v1/admin/email-template-versions/:id/send-test
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req models.SendTestEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.VersionID = idStr
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
// Get template to find type
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
// Send test email
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
}
// GetSettings returns global email settings
// GET /api/v1/admin/email-templates/settings
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
settings, err := h.service.GetSettings(c.Request.Context())
if err != nil {
// Return default settings if none exist
c.JSON(http.StatusOK, gin.H{
"company_name": "BreakPilot",
"sender_name": "BreakPilot",
"sender_email": "noreply@breakpilot.app",
"primary_color": "#2563eb",
"secondary_color": "#64748b",
})
return
}
c.JSON(http.StatusOK, settings)
}
// UpdateSettings updates global email settings
// PUT /api/v1/admin/email-templates/settings
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
var req models.UpdateEmailTemplateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
}
// GetEmailStats returns email statistics
// GET /api/v1/admin/email-templates/stats
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
stats, err := h.service.GetEmailStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetSendLogs returns email send logs
// GET /api/v1/admin/email-templates/logs
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "50")
offsetStr := c.DefaultQuery("offset", "0")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
if limit > 100 {
limit = 100
}
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
}
// GetDefaultContent returns default template content for a type
// GET /api/v1/admin/email-templates/default/:type
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
templateType := c.Param("type")
language := c.DefaultQuery("language", "de")
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
c.JSON(http.StatusOK, gin.H{
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
})
}
// InitializeTemplates initializes default email templates
// POST /api/v1/admin/email-templates/initialize
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists || (role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
}

View File

@@ -0,0 +1,168 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// GDPR / DATA SUBJECT RIGHTS
// ========================================
// GetMyData returns all data we have about the user
func (h *Handler) GetMyData(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Get user info
var user models.User
err = h.db.Pool.QueryRow(ctx, `
SELECT id, external_id, email, role, created_at, updated_at
FROM users WHERE id = $1
`, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt)
// Get consents
consentRows, _ := h.db.Pool.Query(ctx, `
SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version
FROM user_consents uc
JOIN document_versions dv ON uc.document_version_id = dv.id
JOIN legal_documents ld ON dv.document_id = ld.id
WHERE uc.user_id = $1
`, userID)
defer consentRows.Close()
var consents []map[string]interface{}
for consentRows.Next() {
var consented bool
var consentedAt time.Time
var docType, docName, version string
consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version)
consents = append(consents, map[string]interface{}{
"document_type": docType,
"document_name": docName,
"version": version,
"consented": consented,
"consented_at": consentedAt,
})
}
// Get cookie consents
cookieRows, _ := h.db.Pool.Query(ctx, `
SELECT cat.name, cc.consented, cc.updated_at
FROM cookie_consents cc
JOIN cookie_categories cat ON cc.category_id = cat.id
WHERE cc.user_id = $1
`, userID)
defer cookieRows.Close()
var cookieConsents []map[string]interface{}
for cookieRows.Next() {
var name string
var consented bool
var updatedAt time.Time
cookieRows.Scan(&name, &consented, &updatedAt)
cookieConsents = append(cookieConsents, map[string]interface{}{
"category": name,
"consented": consented,
"updated_at": updatedAt,
})
}
// Log data access
h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent)
c.JSON(http.StatusOK, gin.H{
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"created_at": user.CreatedAt,
},
"consents": consents,
"cookie_consents": cookieConsents,
"exported_at": time.Now(),
})
}
// RequestDataExport creates a data export request
func (h *Handler) RequestDataExport(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
var requestID uuid.UUID
err = h.db.Pool.QueryRow(ctx, `
INSERT INTO data_export_requests (user_id, status)
VALUES ($1, 'pending')
RETURNING id
`, userID).Scan(&requestID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"})
return
}
// Log to audit trail
h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent)
c.JSON(http.StatusAccepted, gin.H{
"message": "Export request created. You will be notified when ready.",
"request_id": requestID,
})
}
// RequestDataDeletion creates a data deletion request
func (h *Handler) RequestDataDeletion(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
var req struct {
Reason string `json:"reason"`
}
c.ShouldBindJSON(&req)
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
var requestID uuid.UUID
err = h.db.Pool.QueryRow(ctx, `
INSERT INTO data_deletion_requests (user_id, status, reason)
VALUES ($1, 'pending', $2)
RETURNING id
`, userID, req.Reason).Scan(&requestID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"})
return
}
// Log to audit trail
h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent)
c.JSON(http.StatusAccepted, gin.H{
"message": "Deletion request created. We will process your request within 30 days.",
"request_id": requestID,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,372 @@
package handlers
import (
"context"
"net/http"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// 2FA (TOTP) Endpoints
// ========================================
// Setup2FA initiates 2FA setup
// POST /auth/2fa/setup
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
// Get user email
ctx := context.Background()
user, err := h.authService.GetUserByID(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Setup 2FA
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
if err != nil {
switch err {
case services.ErrTOTPAlreadyEnabled:
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
}
return
}
c.JSON(http.StatusOK, response)
}
// Verify2FASetup verifies the 2FA setup with a code
// POST /auth/2fa/verify-setup
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPAlreadyEnabled:
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
}
// Verify2FAChallenge verifies a 2FA challenge during login
// POST /auth/2fa/verify
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
var req models.Verify2FAChallengeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
var userID *uuid.UUID
var err error
if req.RecoveryCode != "" {
// Verify with recovery code
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
} else {
// Verify with TOTP code
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
}
if err != nil {
switch err {
case services.ErrTOTPChallengeExpired:
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
case services.ErrRecoveryCodeInvalid:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
}
return
}
// Get user and generate tokens
user, err := h.authService.GetUserByID(ctx, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Generate access token
accessToken, err := h.authService.GenerateAccessToken(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Generate refresh token
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
return
}
// Store session
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// We need direct DB access for this, or we need to add a method to AuthService
// For now, we'll return the tokens and let the caller handle session storage
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": "Bearer",
"expires_in": 3600,
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
},
"_session_hash": refreshTokenHash,
"_ip": ipAddress,
"_user_agent": userAgent,
})
}
// Disable2FA disables 2FA for the current user
// POST /auth/2fa/disable
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
err = h.totpService.Disable2FA(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPNotEnabled:
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
}
// Get2FAStatus returns the 2FA status for the current user
// GET /auth/2fa/status
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
ctx := context.Background()
status, err := h.totpService.GetStatus(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
return
}
c.JSON(http.StatusOK, status)
}
// RegenerateRecoveryCodes generates new recovery codes
// POST /auth/2fa/recovery-codes
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPNotEnabled:
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
}
return
}
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
}
// ========================================
// Enhanced Login with 2FA
// ========================================
// LoginWith2FA handles login with optional 2FA
// POST /auth/login
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Attempt login
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
if err != nil {
switch err {
case services.ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
case services.ErrAccountLocked:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
case services.ErrAccountSuspended:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
}
return
}
// Check if 2FA is enabled
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
if twoFactorEnabled {
// Create 2FA challenge
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
return
}
// Return 2FA required response
c.JSON(http.StatusOK, gin.H{
"requires_2fa": true,
"challenge_id": challengeID,
"message": "2FA verification required",
})
return
}
// No 2FA required, return tokens
c.JSON(http.StatusOK, gin.H{
"requires_2fa": false,
"access_token": response.AccessToken,
"refresh_token": response.RefreshToken,
"token_type": "Bearer",
"expires_in": response.ExpiresIn,
"user": map[string]interface{}{
"id": response.User.ID,
"email": response.User.Email,
"name": response.User.Name,
"role": response.User.Role,
},
})
}
// ========================================
// Registration with mandatory 2FA setup
// ========================================
// RegisterWith2FA handles registration with mandatory 2FA setup
// POST /auth/register
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
// Validate password strength
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
return
}
// Register user
user, verificationToken, err := h.authService.Register(ctx, &req)
if err != nil {
switch err {
case services.ErrUserExists:
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
}
return
}
// Setup 2FA immediately
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
if err != nil {
// Non-fatal - user can set up 2FA later, but log it
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please verify your email.",
"user_id": user.ID,
"verification_token": verificationToken, // In production, this would be sent via email
"two_factor_setup": nil,
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please verify your email and complete 2FA setup.",
"user_id": user.ID,
"verification_token": verificationToken, // In production, this would be sent via email
"two_factor_setup": map[string]interface{}{
"secret": twoFAResponse.Secret,
"qr_code": twoFAResponse.QRCodeDataURL,
"recovery_codes": twoFAResponse.RecoveryCodes,
"setup_required": true,
"setup_endpoint": "/auth/2fa/verify-setup",
},
})
}

View File

@@ -6,7 +6,6 @@ import (
"strings"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -292,366 +291,6 @@ func (h *OAuthHandler) Introspect(c *gin.Context) {
})
}
// ========================================
// 2FA (TOTP) Endpoints
// ========================================
// Setup2FA initiates 2FA setup
// POST /auth/2fa/setup
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
// Get user email
ctx := context.Background()
user, err := h.authService.GetUserByID(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Setup 2FA
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
if err != nil {
switch err {
case services.ErrTOTPAlreadyEnabled:
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
}
return
}
c.JSON(http.StatusOK, response)
}
// Verify2FASetup verifies the 2FA setup with a code
// POST /auth/2fa/verify-setup
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPAlreadyEnabled:
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
}
// Verify2FAChallenge verifies a 2FA challenge during login
// POST /auth/2fa/verify
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
var req models.Verify2FAChallengeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
var userID *uuid.UUID
var err error
if req.RecoveryCode != "" {
// Verify with recovery code
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
} else {
// Verify with TOTP code
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
}
if err != nil {
switch err {
case services.ErrTOTPChallengeExpired:
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
case services.ErrRecoveryCodeInvalid:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
}
return
}
// Get user and generate tokens
user, err := h.authService.GetUserByID(ctx, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Generate access token
accessToken, err := h.authService.GenerateAccessToken(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Generate refresh token
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
return
}
// Store session
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// We need direct DB access for this, or we need to add a method to AuthService
// For now, we'll return the tokens and let the caller handle session storage
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": "Bearer",
"expires_in": 3600,
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
},
"_session_hash": refreshTokenHash,
"_ip": ipAddress,
"_user_agent": userAgent,
})
}
// Disable2FA disables 2FA for the current user
// POST /auth/2fa/disable
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
err = h.totpService.Disable2FA(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPNotEnabled:
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
}
// Get2FAStatus returns the 2FA status for the current user
// GET /auth/2fa/status
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
ctx := context.Background()
status, err := h.totpService.GetStatus(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
return
}
c.JSON(http.StatusOK, status)
}
// RegenerateRecoveryCodes generates new recovery codes
// POST /auth/2fa/recovery-codes
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPNotEnabled:
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
}
return
}
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
}
// ========================================
// Enhanced Login with 2FA
// ========================================
// LoginWith2FA handles login with optional 2FA
// POST /auth/login
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Attempt login
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
if err != nil {
switch err {
case services.ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
case services.ErrAccountLocked:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
case services.ErrAccountSuspended:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
}
return
}
// Check if 2FA is enabled
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
if twoFactorEnabled {
// Create 2FA challenge
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
return
}
// Return 2FA required response
c.JSON(http.StatusOK, gin.H{
"requires_2fa": true,
"challenge_id": challengeID,
"message": "2FA verification required",
})
return
}
// No 2FA required, return tokens
c.JSON(http.StatusOK, gin.H{
"requires_2fa": false,
"access_token": response.AccessToken,
"refresh_token": response.RefreshToken,
"token_type": "Bearer",
"expires_in": response.ExpiresIn,
"user": map[string]interface{}{
"id": response.User.ID,
"email": response.User.Email,
"name": response.User.Name,
"role": response.User.Role,
},
})
}
// ========================================
// Registration with mandatory 2FA setup
// ========================================
// RegisterWith2FA handles registration with mandatory 2FA setup
// POST /auth/register
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
// Validate password strength
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
return
}
// Register user
user, verificationToken, err := h.authService.Register(ctx, &req)
if err != nil {
switch err {
case services.ErrUserExists:
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
}
return
}
// Setup 2FA immediately
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
if err != nil {
// Non-fatal - user can set up 2FA later, but log it
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please verify your email.",
"user_id": user.ID,
"verification_token": verificationToken, // In production, this would be sent via email
"two_factor_setup": nil,
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please verify your email and complete 2FA setup.",
"user_id": user.ID,
"verification_token": verificationToken, // In production, this would be sent via email
"two_factor_setup": map[string]interface{}{
"secret": twoFAResponse.Secret,
"qr_code": twoFAResponse.QRCodeDataURL,
"recovery_codes": twoFAResponse.RecoveryCodes,
"setup_required": true,
"setup_endpoint": "/auth/2fa/verify-setup",
},
})
}
// ========================================
// OAuth Client Management (Admin)
// ========================================

View File

@@ -0,0 +1,243 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// Attendance Handlers
// ========================================
// RecordAttendance records attendance for a student
// POST /api/v1/attendance
func (h *SchoolHandlers) RecordAttendance(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.RecordAttendanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, record)
}
// RecordBulkAttendance records attendance for multiple students
// POST /api/v1/classes/:id/attendance
func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
var req struct {
Date string `json:"date" binding:"required"`
SlotID string `json:"slot_id" binding:"required"`
Records []struct {
StudentID string `json:"student_id"`
Status string `json:"status"`
Note *string `json:"note"`
} `json:"records" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slotID, err := uuid.Parse(req.SlotID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"})
return
}
// Convert to the expected type (without JSON tags)
records := make([]struct {
StudentID string
Status string
Note *string
}, len(req.Records))
for i, r := range req.Records {
records[i] = struct {
StudentID string
Status string
Note *string
}{
StudentID: r.StudentID,
Status: r.Status,
Note: r.Note,
}
}
err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"})
}
// GetClassAttendance gets attendance for a class on a specific date
// GET /api/v1/classes/:id/attendance?date=...
func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overview)
}
// GetStudentAttendance gets attendance history for a student
// GET /api/v1/students/:id/attendance?start_date=...&end_date=...
func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) {
studentIDStr := c.Param("id")
studentID, err := uuid.Parse(studentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
var startDate, endDate time.Time
if startDateStr == "" {
startDate = time.Now().AddDate(0, -1, 0) // Last month
} else {
startDate, _ = time.Parse("2006-01-02", startDateStr)
}
if endDateStr == "" {
endDate = time.Now()
} else {
endDate, _ = time.Parse("2006-01-02", endDateStr)
}
records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, records)
}
// ========================================
// Absence Report Handlers
// ========================================
// ReportAbsence allows parents to report absence
// POST /api/v1/absence/report
func (h *SchoolHandlers) ReportAbsence(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.ReportAbsenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, report)
}
// ConfirmAbsence allows teachers to confirm absence
// PUT /api/v1/absence/:id/confirm
func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
reportIDStr := c.Param("id")
reportID, err := uuid.Parse(reportIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
var req struct {
Status string `json:"status" binding:"required"` // "excused" or "unexcused"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"})
}
// GetPendingAbsenceReports gets pending absence reports for a class
// GET /api/v1/classes/:id/absence/pending
func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, reports)
}

View File

@@ -0,0 +1,303 @@
package handlers
import (
"net/http"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// Grade Handlers
// ========================================
// CreateGrade creates a new grade
// POST /api/v1/grades
func (h *SchoolHandlers) CreateGrade(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.CreateGradeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get teacher ID from user ID
teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"})
return
}
grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, grade)
}
// GetStudentGrades gets all grades for a student
// GET /api/v1/students/:id/grades?school_year_id=...
func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) {
studentIDStr := c.Param("id")
studentID, err := uuid.Parse(studentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, grades)
}
// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel)
// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=...
func (h *SchoolHandlers) GetClassGrades(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
subjectIDStr := c.Param("subjectId")
subjectID, err := uuid.Parse(subjectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
semesterStr := c.DefaultQuery("semester", "1")
var semester int
if semesterStr == "1" {
semester = 1
} else {
semester = 2
}
overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overviews)
}
// GetGradeStatistics gets grade statistics for a class/subject
// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=...
func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
subjectIDStr := c.Param("subjectId")
subjectID, err := uuid.Parse(subjectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
semesterStr := c.DefaultQuery("semester", "1")
var semester int
if semesterStr == "1" {
semester = 1
} else {
semester = 2
}
stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ========================================
// Parent Onboarding Handlers
// ========================================
// GenerateOnboardingToken generates a QR code token for parent onboarding
// POST /api/v1/onboarding/tokens
func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
SchoolID string `json:"school_id" binding:"required"`
ClassID string `json:"class_id" binding:"required"`
StudentID string `json:"student_id" binding:"required"`
Role string `json:"role"` // "parent" or "parent_representative"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
schoolID, err := uuid.Parse(req.SchoolID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
classID, err := uuid.Parse(req.ClassID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
studentID, err := uuid.Parse(req.StudentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
role := req.Role
if role == "" {
role = "parent"
}
token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Generate QR code URL
qrURL := "/onboard-parent?token=" + token.Token
c.JSON(http.StatusCreated, gin.H{
"token": token.Token,
"qr_url": qrURL,
"expires_at": token.ExpiresAt,
})
}
// ValidateOnboardingToken validates an onboarding token
// GET /api/v1/onboarding/validate?token=...
func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"})
return
}
// Get student and school info
student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"role": onboardingToken.Role,
"student_name": student.FirstName + " " + student.LastName,
"class_name": class.Name,
"school_name": school.Name,
"expires_at": onboardingToken.ExpiresAt,
})
}
// RedeemOnboardingToken redeems a token and creates parent account
// POST /api/v1/onboarding/redeem
func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"})
}

View File

@@ -355,531 +355,6 @@ func (h *SchoolHandlers) ListSubjects(c *gin.Context) {
c.JSON(http.StatusOK, subjects)
}
// ========================================
// Attendance Handlers
// ========================================
// RecordAttendance records attendance for a student
// POST /api/v1/attendance
func (h *SchoolHandlers) RecordAttendance(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.RecordAttendanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, record)
}
// RecordBulkAttendance records attendance for multiple students
// POST /api/v1/classes/:id/attendance
func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
var req struct {
Date string `json:"date" binding:"required"`
SlotID string `json:"slot_id" binding:"required"`
Records []struct {
StudentID string `json:"student_id"`
Status string `json:"status"`
Note *string `json:"note"`
} `json:"records" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slotID, err := uuid.Parse(req.SlotID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"})
return
}
// Convert to the expected type (without JSON tags)
records := make([]struct {
StudentID string
Status string
Note *string
}, len(req.Records))
for i, r := range req.Records {
records[i] = struct {
StudentID string
Status string
Note *string
}{
StudentID: r.StudentID,
Status: r.Status,
Note: r.Note,
}
}
err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"})
}
// GetClassAttendance gets attendance for a class on a specific date
// GET /api/v1/classes/:id/attendance?date=...
func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overview)
}
// GetStudentAttendance gets attendance history for a student
// GET /api/v1/students/:id/attendance?start_date=...&end_date=...
func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) {
studentIDStr := c.Param("id")
studentID, err := uuid.Parse(studentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
var startDate, endDate time.Time
if startDateStr == "" {
startDate = time.Now().AddDate(0, -1, 0) // Last month
} else {
startDate, _ = time.Parse("2006-01-02", startDateStr)
}
if endDateStr == "" {
endDate = time.Now()
} else {
endDate, _ = time.Parse("2006-01-02", endDateStr)
}
records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, records)
}
// ========================================
// Absence Report Handlers
// ========================================
// ReportAbsence allows parents to report absence
// POST /api/v1/absence/report
func (h *SchoolHandlers) ReportAbsence(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.ReportAbsenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, report)
}
// ConfirmAbsence allows teachers to confirm absence
// PUT /api/v1/absence/:id/confirm
func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
reportIDStr := c.Param("id")
reportID, err := uuid.Parse(reportIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
var req struct {
Status string `json:"status" binding:"required"` // "excused" or "unexcused"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"})
}
// GetPendingAbsenceReports gets pending absence reports for a class
// GET /api/v1/classes/:id/absence/pending
func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, reports)
}
// ========================================
// Grade Handlers
// ========================================
// CreateGrade creates a new grade
// POST /api/v1/grades
func (h *SchoolHandlers) CreateGrade(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.CreateGradeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get teacher ID from user ID
teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"})
return
}
grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, grade)
}
// GetStudentGrades gets all grades for a student
// GET /api/v1/students/:id/grades?school_year_id=...
func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) {
studentIDStr := c.Param("id")
studentID, err := uuid.Parse(studentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, grades)
}
// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel)
// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=...
func (h *SchoolHandlers) GetClassGrades(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
subjectIDStr := c.Param("subjectId")
subjectID, err := uuid.Parse(subjectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
semesterStr := c.DefaultQuery("semester", "1")
var semester int
if semesterStr == "1" {
semester = 1
} else {
semester = 2
}
overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overviews)
}
// GetGradeStatistics gets grade statistics for a class/subject
// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=...
func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
subjectIDStr := c.Param("subjectId")
subjectID, err := uuid.Parse(subjectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
semesterStr := c.DefaultQuery("semester", "1")
var semester int
if semesterStr == "1" {
semester = 1
} else {
semester = 2
}
stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ========================================
// Parent Onboarding Handlers
// ========================================
// GenerateOnboardingToken generates a QR code token for parent onboarding
// POST /api/v1/onboarding/tokens
func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
SchoolID string `json:"school_id" binding:"required"`
ClassID string `json:"class_id" binding:"required"`
StudentID string `json:"student_id" binding:"required"`
Role string `json:"role"` // "parent" or "parent_representative"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
schoolID, err := uuid.Parse(req.SchoolID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
classID, err := uuid.Parse(req.ClassID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
studentID, err := uuid.Parse(req.StudentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
role := req.Role
if role == "" {
role = "parent"
}
token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Generate QR code URL
qrURL := "/onboard-parent?token=" + token.Token
c.JSON(http.StatusCreated, gin.H{
"token": token.Token,
"qr_url": qrURL,
"expires_at": token.ExpiresAt,
})
}
// ValidateOnboardingToken validates an onboarding token
// GET /api/v1/onboarding/validate?token=...
func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"})
return
}
// Get student and school info
student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"role": onboardingToken.Role,
"student_name": student.FirstName + " " + student.LastName,
"class_name": class.Name,
"school_name": school.Name,
"expires_at": onboardingToken.ExpiresAt,
})
}
// RedeemOnboardingToken redeems a token and creates parent account
// POST /api/v1/onboarding/redeem
func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"})
}
// ========================================
// Register Routes
// ========================================

View File

@@ -0,0 +1,237 @@
package models
import (
"time"
"github.com/google/uuid"
)
// LegalDocument represents a type of legal document (e.g., Terms, Privacy Policy)
type LegalDocument struct {
ID uuid.UUID `json:"id" db:"id"`
Type string `json:"type" db:"type"` // 'terms', 'privacy', 'cookies', 'community'
Name string `json:"name" db:"name"`
Description *string `json:"description" db:"description"`
IsMandatory bool `json:"is_mandatory" db:"is_mandatory"`
IsActive bool `json:"is_active" db:"is_active"`
SortOrder int `json:"sort_order" db:"sort_order"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// DocumentVersion represents a specific version of a legal document
type DocumentVersion struct {
ID uuid.UUID `json:"id" db:"id"`
DocumentID uuid.UUID `json:"document_id" db:"document_id"`
Version string `json:"version" db:"version"` // Semver: 1.0.0, 1.1.0
Language string `json:"language" db:"language"` // ISO 639-1: de, en
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"` // HTML or Markdown
Summary *string `json:"summary" db:"summary"` // Summary of changes
Status string `json:"status" db:"status"` // 'draft', 'review', 'approved', 'scheduled', 'published', 'archived'
PublishedAt *time.Time `json:"published_at" db:"published_at"`
ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"`
CreatedBy *uuid.UUID `json:"created_by" db:"created_by"`
ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"`
ApprovedAt *time.Time `json:"approved_at" db:"approved_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// UserConsent represents a user's consent to a document version
type UserConsent struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"`
Consented bool `json:"consented" db:"consented"`
IPAddress *string `json:"ip_address" db:"ip_address"`
UserAgent *string `json:"user_agent" db:"user_agent"`
ConsentedAt time.Time `json:"consented_at" db:"consented_at"`
WithdrawnAt *time.Time `json:"withdrawn_at" db:"withdrawn_at"`
}
// AuditLog represents an audit trail entry for GDPR compliance
type AuditLog struct {
ID uuid.UUID `json:"id" db:"id"`
UserID *uuid.UUID `json:"user_id" db:"user_id"`
Action string `json:"action" db:"action"` // 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete'
EntityType *string `json:"entity_type" db:"entity_type"` // 'document', 'cookie_category'
EntityID *uuid.UUID `json:"entity_id" db:"entity_id"`
Details *string `json:"details" db:"details"` // JSON string
IPAddress *string `json:"ip_address" db:"ip_address"`
UserAgent *string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// DataExportRequest represents a user's request to export their data
type DataExportRequest struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed'
DownloadURL *string `json:"download_url" db:"download_url"`
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
}
// DataDeletionRequest represents a user's request to delete their data
type DataDeletionRequest struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed'
Reason *string `json:"reason" db:"reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ProcessedAt *time.Time `json:"processed_at" db:"processed_at"`
ProcessedBy *uuid.UUID `json:"processed_by" db:"processed_by"`
}
// VersionApproval tracks the approval workflow
type VersionApproval struct {
ID uuid.UUID `json:"id" db:"id"`
VersionID uuid.UUID `json:"version_id" db:"version_id"`
ApproverID uuid.UUID `json:"approver_id" db:"approver_id"`
Action string `json:"action" db:"action"` // 'submitted_for_review', 'approved', 'rejected', 'published'
Comment *string `json:"comment,omitempty" db:"comment"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ConsentDeadline tracks consent deadlines per user
type ConsentDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"`
DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"`
ReminderCount int `json:"reminder_count" db:"reminder_count"`
LastReminderAt *time.Time `json:"last_reminder_at,omitempty" db:"last_reminder_at"`
ConsentGivenAt *time.Time `json:"consent_given_at,omitempty" db:"consent_given_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// AccountSuspension tracks account suspensions
type AccountSuspension struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Reason string `json:"reason" db:"reason"` // 'consent_deadline_exceeded'
Details *string `json:"details,omitempty" db:"details"` // JSON
SuspendedAt time.Time `json:"suspended_at" db:"suspended_at"`
LiftedAt *time.Time `json:"lifted_at,omitempty" db:"lifted_at"`
LiftedReason *string `json:"lifted_reason,omitempty" db:"lifted_reason"`
}
// ========================================
// Consent DTOs
// ========================================
// CreateConsentRequest is the request body for creating a consent
type CreateConsentRequest struct {
DocumentType string `json:"document_type" binding:"required"`
VersionID string `json:"version_id" binding:"required"`
Consented bool `json:"consented"`
}
// ConsentCheckResponse is the response for checking consent status
type ConsentCheckResponse struct {
HasConsent bool `json:"has_consent"`
CurrentVersionID *string `json:"current_version_id,omitempty"`
ConsentedVersion *string `json:"consented_version,omitempty"`
NeedsUpdate bool `json:"needs_update"`
ConsentedAt *time.Time `json:"consented_at,omitempty"`
}
// DocumentWithVersion combines document info with its latest published version
type DocumentWithVersion struct {
Document LegalDocument `json:"document"`
LatestVersion *DocumentVersion `json:"latest_version,omitempty"`
}
// ConsentHistory represents a user's consent history for a document
type ConsentHistory struct {
Document LegalDocument `json:"document"`
Version DocumentVersion `json:"version"`
Consent UserConsent `json:"consent"`
}
// ConsentStats represents statistics about consents
type ConsentStats struct {
TotalUsers int `json:"total_users"`
ConsentedUsers int `json:"consented_users"`
ConsentRate float64 `json:"consent_rate"`
RecentConsents int `json:"recent_consents"` // Last 7 days
RecentWithdrawals int `json:"recent_withdrawals"`
}
// MyDataResponse represents all data we have about a user
type MyDataResponse struct {
User User `json:"user"`
Consents []ConsentHistory `json:"consents"`
CookieConsents []CookieConsent `json:"cookie_consents"`
AuditLog []AuditLog `json:"audit_log"`
ExportedAt time.Time `json:"exported_at"`
}
// CreateDocumentRequest is the request body for creating a document
type CreateDocumentRequest struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
Description *string `json:"description"`
IsMandatory bool `json:"is_mandatory"`
}
// CreateVersionRequest is the request body for creating a document version
type CreateVersionRequest struct {
DocumentID string `json:"document_id" binding:"required"`
Version string `json:"version" binding:"required"`
Language string `json:"language" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Summary *string `json:"summary"`
}
// UpdateVersionRequest is the request body for updating a version
type UpdateVersionRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
Summary *string `json:"summary"`
Status *string `json:"status"`
}
// SubmitForReviewRequest for submitting a version for review
type SubmitForReviewRequest struct {
Comment *string `json:"comment"`
}
// ApproveVersionRequest for approving a version (DSB)
type ApproveVersionRequest struct {
Comment *string `json:"comment"`
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601 datetime for scheduled publishing
}
// RejectVersionRequest for rejecting a version
type RejectVersionRequest struct {
Comment string `json:"comment" binding:"required"`
}
// VersionCompareResponse for comparing versions
type VersionCompareResponse struct {
Published *DocumentVersion `json:"published,omitempty"`
Draft *DocumentVersion `json:"draft"`
Diff *string `json:"diff,omitempty"`
Approvals []VersionApproval `json:"approvals"`
}
// PendingConsentResponse for pending consents with deadline info
type PendingConsentResponse struct {
Document LegalDocument `json:"document"`
Version DocumentVersion `json:"version"`
DeadlineAt time.Time `json:"deadline_at"`
DaysLeft int `json:"days_left"`
IsOverdue bool `json:"is_overdue"`
}
// AccountStatusResponse for account status check
type AccountStatusResponse struct {
Status string `json:"status"` // 'active', 'suspended'
PendingConsents []PendingConsentResponse `json:"pending_consents,omitempty"`
SuspensionReason *string `json:"suspension_reason,omitempty"`
CanAccess bool `json:"can_access"`
}

View File

@@ -0,0 +1,118 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CookieCategory represents a category of cookies
type CookieCategory struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"` // 'necessary', 'functional', 'analytics', 'marketing'
DisplayNameDE string `json:"display_name_de" db:"display_name_de"`
DisplayNameEN *string `json:"display_name_en" db:"display_name_en"`
DescriptionDE *string `json:"description_de" db:"description_de"`
DescriptionEN *string `json:"description_en" db:"description_en"`
IsMandatory bool `json:"is_mandatory" db:"is_mandatory"`
SortOrder int `json:"sort_order" db:"sort_order"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CookieConsent represents a user's cookie preferences
type CookieConsent struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
CategoryID uuid.UUID `json:"category_id" db:"category_id"`
Consented bool `json:"consented" db:"consented"`
ConsentedAt time.Time `json:"consented_at" db:"consented_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CookieConsentRequest is the request body for setting cookie preferences
type CookieConsentRequest struct {
Categories []CookieCategoryConsent `json:"categories" binding:"required"`
}
// CookieCategoryConsent represents consent for a single cookie category
type CookieCategoryConsent struct {
CategoryID string `json:"category_id" binding:"required"`
Consented bool `json:"consented"`
}
// CookieStats represents statistics about cookie consents
type CookieStats struct {
Category string `json:"category"`
TotalUsers int `json:"total_users"`
ConsentedUsers int `json:"consented_users"`
ConsentRate float64 `json:"consent_rate"`
}
// CreateCookieCategoryRequest is the request body for creating a cookie category
type CreateCookieCategoryRequest struct {
Name string `json:"name" binding:"required"`
DisplayNameDE string `json:"display_name_de" binding:"required"`
DisplayNameEN *string `json:"display_name_en"`
DescriptionDE *string `json:"description_de"`
DescriptionEN *string `json:"description_en"`
IsMandatory bool `json:"is_mandatory"`
SortOrder int `json:"sort_order"`
}
// ========================================
// Notification Models
// ========================================
// Notification represents a user notification
type Notification struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Type string `json:"type" db:"type"` // 'new_version', 'consent_reminder', 'account_warning'
Channel string `json:"channel" db:"channel"` // 'email', 'in_app', 'push'
Title string `json:"title" db:"title"`
Body string `json:"body" db:"body"`
Data *string `json:"data,omitempty" db:"data"` // JSON string
ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// PushSubscription for Web Push notifications
type PushSubscription struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Endpoint string `json:"endpoint" db:"endpoint"`
P256dh string `json:"p256dh" db:"p256dh"`
Auth string `json:"auth" db:"auth"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// NotificationPreferences for user notification settings
type NotificationPreferences struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
EmailEnabled bool `json:"email_enabled" db:"email_enabled"`
PushEnabled bool `json:"push_enabled" db:"push_enabled"`
InAppEnabled bool `json:"in_app_enabled" db:"in_app_enabled"`
ReminderFrequency string `json:"reminder_frequency" db:"reminder_frequency"` // 'daily', 'weekly', 'never'
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SubscribePushRequest for subscribing to push notifications
type SubscribePushRequest struct {
Endpoint string `json:"endpoint" binding:"required"`
P256dh string `json:"p256dh" binding:"required"`
Auth string `json:"auth" binding:"required"`
}
// UpdateNotificationPreferencesRequest for updating preferences
type UpdateNotificationPreferencesRequest struct {
EmailEnabled *bool `json:"email_enabled"`
PushEnabled *bool `json:"push_enabled"`
InAppEnabled *bool `json:"in_app_enabled"`
ReminderFrequency *string `json:"reminder_frequency"`
}

View File

@@ -0,0 +1,403 @@
package models
import (
"time"
"github.com/google/uuid"
)
// ========================================
// DSGVO Betroffenenanfragen (DSR)
// Data Subject Request Management
// Art. 15, 16, 17, 18, 20 DSGVO
// ========================================
// DSRRequestType defines the GDPR article for the request
type DSRRequestType string
const (
DSRTypeAccess DSRRequestType = "access" // Art. 15 - Auskunftsrecht
DSRTypeRectification DSRRequestType = "rectification" // Art. 16 - Berichtigungsrecht
DSRTypeErasure DSRRequestType = "erasure" // Art. 17 - Löschungsrecht
DSRTypeRestriction DSRRequestType = "restriction" // Art. 18 - Einschränkungsrecht
DSRTypePortability DSRRequestType = "portability" // Art. 20 - Datenübertragbarkeit
)
// DSRStatus defines the workflow state of a DSR
type DSRStatus string
const (
DSRStatusIntake DSRStatus = "intake" // Eingegangen
DSRStatusIdentityVerification DSRStatus = "identity_verification" // Identitätsprüfung
DSRStatusProcessing DSRStatus = "processing" // In Bearbeitung
DSRStatusCompleted DSRStatus = "completed" // Abgeschlossen
DSRStatusRejected DSRStatus = "rejected" // Abgelehnt
DSRStatusCancelled DSRStatus = "cancelled" // Storniert
)
// DSRPriority defines the priority level of a DSR
type DSRPriority string
const (
DSRPriorityNormal DSRPriority = "normal"
DSRPriorityExpedited DSRPriority = "expedited" // Art. 16, 17, 18 - beschleunigt
DSRPriorityUrgent DSRPriority = "urgent"
)
// DSRSource defines where the request came from
type DSRSource string
const (
DSRSourceAPI DSRSource = "api" // Über API/Self-Service
DSRSourceAdminPanel DSRSource = "admin_panel" // Manuell im Admin
DSRSourceEmail DSRSource = "email" // Per E-Mail
DSRSourcePostal DSRSource = "postal" // Per Post
)
// Art. 17(3) Exception Types
const (
DSRExceptionFreedomExpression = "freedom_expression" // Art. 17(3)(a)
DSRExceptionLegalObligation = "legal_obligation" // Art. 17(3)(b)
DSRExceptionPublicInterest = "public_interest" // Art. 17(3)(c)
DSRExceptionPublicHealth = "public_health" // Art. 17(3)(c)
DSRExceptionArchiving = "archiving" // Art. 17(3)(d)
DSRExceptionLegalClaims = "legal_claims" // Art. 17(3)(e)
)
// DataSubjectRequest represents a GDPR data subject request
type DataSubjectRequest struct {
ID uuid.UUID `json:"id" db:"id"`
UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"`
RequestNumber string `json:"request_number" db:"request_number"`
RequestType DSRRequestType `json:"request_type" db:"request_type"`
Status DSRStatus `json:"status" db:"status"`
Priority DSRPriority `json:"priority" db:"priority"`
Source DSRSource `json:"source" db:"source"`
RequesterEmail string `json:"requester_email" db:"requester_email"`
RequesterName *string `json:"requester_name,omitempty" db:"requester_name"`
RequesterPhone *string `json:"requester_phone,omitempty" db:"requester_phone"`
IdentityVerified bool `json:"identity_verified" db:"identity_verified"`
IdentityVerifiedAt *time.Time `json:"identity_verified_at,omitempty" db:"identity_verified_at"`
IdentityVerifiedBy *uuid.UUID `json:"identity_verified_by,omitempty" db:"identity_verified_by"`
IdentityVerificationMethod *string `json:"identity_verification_method,omitempty" db:"identity_verification_method"`
RequestDetails map[string]interface{} `json:"request_details" db:"request_details"`
DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"`
LegalDeadlineDays int `json:"legal_deadline_days" db:"legal_deadline_days"`
ExtendedDeadlineAt *time.Time `json:"extended_deadline_at,omitempty" db:"extended_deadline_at"`
ExtensionReason *string `json:"extension_reason,omitempty" db:"extension_reason"`
AssignedTo *uuid.UUID `json:"assigned_to,omitempty" db:"assigned_to"`
ProcessingNotes *string `json:"processing_notes,omitempty" db:"processing_notes"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
CompletedBy *uuid.UUID `json:"completed_by,omitempty" db:"completed_by"`
ResultSummary *string `json:"result_summary,omitempty" db:"result_summary"`
ResultData map[string]interface{} `json:"result_data,omitempty" db:"result_data"`
RejectedAt *time.Time `json:"rejected_at,omitempty" db:"rejected_at"`
RejectedBy *uuid.UUID `json:"rejected_by,omitempty" db:"rejected_by"`
RejectionReason *string `json:"rejection_reason,omitempty" db:"rejection_reason"`
RejectionLegalBasis *string `json:"rejection_legal_basis,omitempty" db:"rejection_legal_basis"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"`
}
// DSRStatusHistory tracks status changes for audit trail
type DSRStatusHistory struct {
ID uuid.UUID `json:"id" db:"id"`
RequestID uuid.UUID `json:"request_id" db:"request_id"`
FromStatus *DSRStatus `json:"from_status,omitempty" db:"from_status"`
ToStatus DSRStatus `json:"to_status" db:"to_status"`
ChangedBy *uuid.UUID `json:"changed_by,omitempty" db:"changed_by"`
Comment *string `json:"comment,omitempty" db:"comment"`
Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// DSRCommunication tracks all communications related to a DSR
type DSRCommunication struct {
ID uuid.UUID `json:"id" db:"id"`
RequestID uuid.UUID `json:"request_id" db:"request_id"`
Direction string `json:"direction" db:"direction"`
Channel string `json:"channel" db:"channel"`
CommunicationType string `json:"communication_type" db:"communication_type"`
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty" db:"template_version_id"`
Subject *string `json:"subject,omitempty" db:"subject"`
BodyHTML *string `json:"body_html,omitempty" db:"body_html"`
BodyText *string `json:"body_text,omitempty" db:"body_text"`
RecipientEmail *string `json:"recipient_email,omitempty" db:"recipient_email"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
ErrorMessage *string `json:"error_message,omitempty" db:"error_message"`
Attachments []map[string]interface{} `json:"attachments,omitempty" db:"attachments"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"`
}
// DSRTemplate represents a template type for DSR communications
type DSRTemplate struct {
ID uuid.UUID `json:"id" db:"id"`
TemplateType string `json:"template_type" db:"template_type"`
Name string `json:"name" db:"name"`
Description *string `json:"description,omitempty" db:"description"`
RequestTypes []string `json:"request_types" db:"request_types"`
IsActive bool `json:"is_active" db:"is_active"`
SortOrder int `json:"sort_order" db:"sort_order"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// DSRTemplateVersion represents a versioned template for DSR communications
type DSRTemplateVersion struct {
ID uuid.UUID `json:"id" db:"id"`
TemplateID uuid.UUID `json:"template_id" db:"template_id"`
Version string `json:"version" db:"version"`
Language string `json:"language" db:"language"`
Subject string `json:"subject" db:"subject"`
BodyHTML string `json:"body_html" db:"body_html"`
BodyText string `json:"body_text" db:"body_text"`
Status string `json:"status" db:"status"`
PublishedAt *time.Time `json:"published_at,omitempty" db:"published_at"`
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"`
ApprovedBy *uuid.UUID `json:"approved_by,omitempty" db:"approved_by"`
ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// DSRExceptionCheck tracks Art. 17(3) exception evaluations for erasure requests
type DSRExceptionCheck struct {
ID uuid.UUID `json:"id" db:"id"`
RequestID uuid.UUID `json:"request_id" db:"request_id"`
ExceptionType string `json:"exception_type" db:"exception_type"`
Description string `json:"description" db:"description"`
Applies *bool `json:"applies,omitempty" db:"applies"`
CheckedBy *uuid.UUID `json:"checked_by,omitempty" db:"checked_by"`
CheckedAt *time.Time `json:"checked_at,omitempty" db:"checked_at"`
Notes *string `json:"notes,omitempty" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ========================================
// DSR DTOs
// ========================================
// CreateDSRRequest for creating a new data subject request
type CreateDSRRequest struct {
RequestType string `json:"request_type" binding:"required"`
RequesterEmail string `json:"requester_email" binding:"required,email"`
RequesterName *string `json:"requester_name"`
RequesterPhone *string `json:"requester_phone"`
Source string `json:"source"`
RequestDetails map[string]interface{} `json:"request_details"`
Priority string `json:"priority"`
}
// UpdateDSRRequest for updating a DSR
type UpdateDSRRequest struct {
Status *string `json:"status"`
AssignedTo *string `json:"assigned_to"`
ProcessingNotes *string `json:"processing_notes"`
ExtendDeadline *bool `json:"extend_deadline"`
ExtensionReason *string `json:"extension_reason"`
RequestDetails map[string]interface{} `json:"request_details"`
Priority *string `json:"priority"`
}
// VerifyDSRIdentityRequest for verifying identity of requester
type VerifyDSRIdentityRequest struct {
Method string `json:"method" binding:"required"`
Comment *string `json:"comment"`
}
// CompleteDSRRequest for completing a DSR
type CompleteDSRRequest struct {
ResultSummary string `json:"result_summary" binding:"required"`
ResultData map[string]interface{} `json:"result_data"`
}
// RejectDSRRequest for rejecting a DSR
type RejectDSRRequest struct {
Reason string `json:"reason" binding:"required"`
LegalBasis string `json:"legal_basis" binding:"required"`
}
// ExtendDSRDeadlineRequest for extending a DSR deadline
type ExtendDSRDeadlineRequest struct {
Reason string `json:"reason" binding:"required"`
Days int `json:"days"`
}
// AssignDSRRequest for assigning a DSR to a handler
type AssignDSRRequest struct {
AssigneeID string `json:"assignee_id" binding:"required"`
Comment *string `json:"comment"`
}
// SendDSRCommunicationRequest for sending a communication
type SendDSRCommunicationRequest struct {
CommunicationType string `json:"communication_type" binding:"required"`
TemplateVersionID *string `json:"template_version_id"`
CustomSubject *string `json:"custom_subject"`
CustomBody *string `json:"custom_body"`
Variables map[string]string `json:"variables"`
}
// UpdateDSRExceptionCheckRequest for updating an exception check
type UpdateDSRExceptionCheckRequest struct {
Applies bool `json:"applies"`
Notes *string `json:"notes"`
}
// DSRListFilters for filtering DSR list
type DSRListFilters struct {
Status *string `form:"status"`
RequestType *string `form:"request_type"`
AssignedTo *string `form:"assigned_to"`
Priority *string `form:"priority"`
OverdueOnly bool `form:"overdue_only"`
FromDate *time.Time `form:"from_date"`
ToDate *time.Time `form:"to_date"`
Search *string `form:"search"`
}
// DSRDashboardStats for the admin dashboard
type DSRDashboardStats struct {
TotalRequests int `json:"total_requests"`
PendingRequests int `json:"pending_requests"`
OverdueRequests int `json:"overdue_requests"`
CompletedThisMonth int `json:"completed_this_month"`
AverageProcessingDays float64 `json:"average_processing_days"`
ByType map[string]int `json:"by_type"`
ByStatus map[string]int `json:"by_status"`
UpcomingDeadlines []DataSubjectRequest `json:"upcoming_deadlines"`
}
// DSRWithDetails combines DSR with related data
type DSRWithDetails struct {
Request DataSubjectRequest `json:"request"`
StatusHistory []DSRStatusHistory `json:"status_history"`
Communications []DSRCommunication `json:"communications"`
ExceptionChecks []DSRExceptionCheck `json:"exception_checks,omitempty"`
AssigneeName *string `json:"assignee_name,omitempty"`
CreatorName *string `json:"creator_name,omitempty"`
}
// DSRTemplateWithVersions combines template with versions
type DSRTemplateWithVersions struct {
Template DSRTemplate `json:"template"`
LatestVersion *DSRTemplateVersion `json:"latest_version,omitempty"`
Versions []DSRTemplateVersion `json:"versions,omitempty"`
}
// CreateDSRTemplateVersionRequest for creating a template version
type CreateDSRTemplateVersionRequest struct {
TemplateID string `json:"template_id" binding:"required"`
Version string `json:"version" binding:"required"`
Language string `json:"language" binding:"required"`
Subject string `json:"subject" binding:"required"`
BodyHTML string `json:"body_html" binding:"required"`
BodyText string `json:"body_text" binding:"required"`
}
// UpdateDSRTemplateVersionRequest for updating a template version
type UpdateDSRTemplateVersionRequest struct {
Subject *string `json:"subject"`
BodyHTML *string `json:"body_html"`
BodyText *string `json:"body_text"`
Status *string `json:"status"`
}
// PreviewDSRTemplateRequest for previewing a template with variables
type PreviewDSRTemplateRequest struct {
Variables map[string]string `json:"variables"`
}
// DSRTemplatePreviewResponse for template preview
type DSRTemplatePreviewResponse struct {
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
}
// ========================================
// DSR Helper Methods
// ========================================
// Label returns German label for request type
func (rt DSRRequestType) Label() string {
switch rt {
case DSRTypeAccess:
return "Auskunftsanfrage (Art. 15)"
case DSRTypeRectification:
return "Berichtigungsanfrage (Art. 16)"
case DSRTypeErasure:
return "Löschanfrage (Art. 17)"
case DSRTypeRestriction:
return "Einschränkungsanfrage (Art. 18)"
case DSRTypePortability:
return "Datenübertragung (Art. 20)"
default:
return string(rt)
}
}
// DeadlineDays returns the legal deadline in days for request type
func (rt DSRRequestType) DeadlineDays() int {
switch rt {
case DSRTypeAccess, DSRTypePortability:
return 30 // 1 month
case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction:
return 14 // 2 weeks (expedited per BDSG)
default:
return 30
}
}
// IsExpedited returns whether this request type should be processed expeditiously
func (rt DSRRequestType) IsExpedited() bool {
switch rt {
case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction:
return true
default:
return false
}
}
// Label returns German label for status
func (s DSRStatus) Label() string {
switch s {
case DSRStatusIntake:
return "Eingang"
case DSRStatusIdentityVerification:
return "Identitätsprüfung"
case DSRStatusProcessing:
return "In Bearbeitung"
case DSRStatusCompleted:
return "Abgeschlossen"
case DSRStatusRejected:
return "Abgelehnt"
case DSRStatusCancelled:
return "Storniert"
default:
return string(s)
}
}
// IsValidDSRRequestType checks if a string is a valid DSR request type
func IsValidDSRRequestType(reqType string) bool {
switch DSRRequestType(reqType) {
case DSRTypeAccess, DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction, DSRTypePortability:
return true
default:
return false
}
}
// IsValidDSRStatus checks if a string is a valid DSR status
func IsValidDSRStatus(status string) bool {
switch DSRStatus(status) {
case DSRStatusIntake, DSRStatusIdentityVerification, DSRStatusProcessing,
DSRStatusCompleted, DSRStatusRejected, DSRStatusCancelled:
return true
default:
return false
}
}

View File

@@ -0,0 +1,191 @@
package models
import (
"time"
"github.com/google/uuid"
)
// EmailTemplateType defines the types of transactional emails
const (
// Auth & Security
EmailTypeWelcome = "welcome"
EmailTypeEmailVerification = "email_verification"
EmailTypePasswordReset = "password_reset"
EmailTypePasswordChanged = "password_changed"
EmailType2FAEnabled = "2fa_enabled"
EmailType2FADisabled = "2fa_disabled"
EmailTypeNewDeviceLogin = "new_device_login"
EmailTypeSuspiciousActivity = "suspicious_activity"
EmailTypeAccountLocked = "account_locked"
EmailTypeAccountUnlocked = "account_unlocked"
// Account Lifecycle
EmailTypeDeletionRequested = "deletion_requested"
EmailTypeDeletionConfirmed = "deletion_confirmed"
EmailTypeDataExportReady = "data_export_ready"
EmailTypeEmailChanged = "email_changed"
EmailTypeEmailChangeVerify = "email_change_verify"
// Consent-related
EmailTypeNewVersionPublished = "new_version_published"
EmailTypeConsentReminder = "consent_reminder"
EmailTypeConsentDeadlineWarning = "consent_deadline_warning"
EmailTypeAccountSuspended = "account_suspended"
)
// EmailTemplate represents a template for transactional emails (like LegalDocument)
type EmailTemplate struct {
ID uuid.UUID `json:"id" db:"id"`
Type string `json:"type" db:"type"` // One of EmailType constants
Name string `json:"name" db:"name"` // Human-readable name
Description *string `json:"description" db:"description"`
IsActive bool `json:"is_active" db:"is_active"`
SortOrder int `json:"sort_order" db:"sort_order"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// EmailTemplateVersion represents a specific version of an email template (like DocumentVersion)
type EmailTemplateVersion struct {
ID uuid.UUID `json:"id" db:"id"`
TemplateID uuid.UUID `json:"template_id" db:"template_id"`
Version string `json:"version" db:"version"` // Semver: 1.0.0
Language string `json:"language" db:"language"` // ISO 639-1: de, en
Subject string `json:"subject" db:"subject"` // Email subject line
BodyHTML string `json:"body_html" db:"body_html"` // HTML version
BodyText string `json:"body_text" db:"body_text"` // Plain text version
Summary *string `json:"summary" db:"summary"` // Change summary
Status string `json:"status" db:"status"` // draft, review, approved, published, archived
PublishedAt *time.Time `json:"published_at" db:"published_at"`
ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"`
CreatedBy *uuid.UUID `json:"created_by" db:"created_by"`
ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"`
ApprovedAt *time.Time `json:"approved_at" db:"approved_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// EmailTemplateApproval tracks approval workflow for email templates
type EmailTemplateApproval struct {
ID uuid.UUID `json:"id" db:"id"`
VersionID uuid.UUID `json:"version_id" db:"version_id"`
ApproverID uuid.UUID `json:"approver_id" db:"approver_id"`
Action string `json:"action" db:"action"` // submitted_for_review, approved, rejected, published
Comment *string `json:"comment,omitempty" db:"comment"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// EmailSendLog tracks sent emails for audit purposes
type EmailSendLog struct {
ID uuid.UUID `json:"id" db:"id"`
UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"`
VersionID uuid.UUID `json:"version_id" db:"version_id"`
Recipient string `json:"recipient" db:"recipient"` // Email address
Subject string `json:"subject" db:"subject"`
Status string `json:"status" db:"status"` // queued, sent, delivered, bounced, failed
ErrorMsg *string `json:"error_msg,omitempty" db:"error_msg"`
Variables *string `json:"variables,omitempty" db:"variables"` // JSON of template variables used
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
DeliveredAt *time.Time `json:"delivered_at,omitempty" db:"delivered_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// EmailTemplateSettings stores global email settings (logo, signature, etc.)
type EmailTemplateSettings struct {
ID uuid.UUID `json:"id" db:"id"`
LogoURL *string `json:"logo_url" db:"logo_url"`
LogoBase64 *string `json:"logo_base64" db:"logo_base64"` // For embedding in emails
CompanyName string `json:"company_name" db:"company_name"`
SenderName string `json:"sender_name" db:"sender_name"`
SenderEmail string `json:"sender_email" db:"sender_email"`
ReplyToEmail *string `json:"reply_to_email" db:"reply_to_email"`
FooterHTML *string `json:"footer_html" db:"footer_html"`
FooterText *string `json:"footer_text" db:"footer_text"`
PrimaryColor string `json:"primary_color" db:"primary_color"` // Hex color
SecondaryColor string `json:"secondary_color" db:"secondary_color"` // Hex color
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
UpdatedBy *uuid.UUID `json:"updated_by" db:"updated_by"`
}
// ========================================
// E-Mail Template DTOs
// ========================================
// CreateEmailTemplateRequest for creating a new email template type
type CreateEmailTemplateRequest struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
Description *string `json:"description"`
}
// CreateEmailTemplateVersionRequest for creating a new version of an email template
type CreateEmailTemplateVersionRequest struct {
TemplateID string `json:"template_id" binding:"required"`
Version string `json:"version" binding:"required"`
Language string `json:"language" binding:"required"`
Subject string `json:"subject" binding:"required"`
BodyHTML string `json:"body_html" binding:"required"`
BodyText string `json:"body_text" binding:"required"`
Summary *string `json:"summary"`
}
// UpdateEmailTemplateVersionRequest for updating a version
type UpdateEmailTemplateVersionRequest struct {
Subject *string `json:"subject"`
BodyHTML *string `json:"body_html"`
BodyText *string `json:"body_text"`
Summary *string `json:"summary"`
Status *string `json:"status"`
}
// UpdateEmailTemplateSettingsRequest for updating global settings
type UpdateEmailTemplateSettingsRequest struct {
LogoURL *string `json:"logo_url"`
LogoBase64 *string `json:"logo_base64"`
CompanyName *string `json:"company_name"`
SenderName *string `json:"sender_name"`
SenderEmail *string `json:"sender_email"`
ReplyToEmail *string `json:"reply_to_email"`
FooterHTML *string `json:"footer_html"`
FooterText *string `json:"footer_text"`
PrimaryColor *string `json:"primary_color"`
SecondaryColor *string `json:"secondary_color"`
}
// EmailTemplateWithVersion combines template info with its latest published version
type EmailTemplateWithVersion struct {
Template EmailTemplate `json:"template"`
LatestVersion *EmailTemplateVersion `json:"latest_version,omitempty"`
}
// SendTestEmailRequest for sending a test email
type SendTestEmailRequest struct {
VersionID string `json:"version_id" binding:"required"`
Recipient string `json:"recipient" binding:"required,email"`
Variables map[string]string `json:"variables"` // Template variable overrides
}
// EmailPreviewResponse for previewing an email
type EmailPreviewResponse struct {
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
}
// EmailTemplateVariables defines available variables for each template type
type EmailTemplateVariables struct {
TemplateType string `json:"template_type"`
Variables []string `json:"variables"`
Descriptions map[string]string `json:"descriptions"`
}
// EmailStats represents statistics about email sends
type EmailStats struct {
TotalSent int `json:"total_sent"`
Delivered int `json:"delivered"`
Bounced int `json:"bounced"`
Failed int `json:"failed"`
DeliveryRate float64 `json:"delivery_rate"`
RecentSent int `json:"recent_sent"` // Last 7 days
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
package models
import (
"time"
"github.com/google/uuid"
)
// OAuthClient represents a registered OAuth 2.0 client application
type OAuthClient struct {
ID uuid.UUID `json:"id" db:"id"`
ClientID string `json:"client_id" db:"client_id"`
ClientSecret string `json:"-" db:"client_secret"` // Never expose in JSON
Name string `json:"name" db:"name"`
Description *string `json:"description,omitempty" db:"description"`
RedirectURIs []string `json:"redirect_uris" db:"redirect_uris"` // JSON array
Scopes []string `json:"scopes" db:"scopes"` // Allowed scopes
GrantTypes []string `json:"grant_types" db:"grant_types"` // authorization_code, refresh_token
IsPublic bool `json:"is_public" db:"is_public"` // Public clients (SPAs) don't have secret
IsActive bool `json:"is_active" db:"is_active"`
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// OAuthAuthorizationCode represents an authorization code for the OAuth flow
type OAuthAuthorizationCode struct {
ID uuid.UUID `json:"id" db:"id"`
Code string `json:"-" db:"code"` // Hashed
ClientID string `json:"client_id" db:"client_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
RedirectURI string `json:"redirect_uri" db:"redirect_uri"`
Scopes []string `json:"scopes" db:"scopes"`
CodeChallenge *string `json:"-" db:"code_challenge"` // For PKCE
CodeChallengeMethod *string `json:"-" db:"code_challenge_method"` // S256 or plain
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// OAuthAccessToken represents an OAuth access token
type OAuthAccessToken struct {
ID uuid.UUID `json:"id" db:"id"`
TokenHash string `json:"-" db:"token_hash"`
ClientID string `json:"client_id" db:"client_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Scopes []string `json:"scopes" db:"scopes"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// OAuthRefreshToken represents an OAuth refresh token
type OAuthRefreshToken struct {
ID uuid.UUID `json:"id" db:"id"`
TokenHash string `json:"-" db:"token_hash"`
AccessTokenID uuid.UUID `json:"access_token_id" db:"access_token_id"`
ClientID string `json:"client_id" db:"client_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Scopes []string `json:"scopes" db:"scopes"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// OAuthAuthorizeRequest for the authorization endpoint
type OAuthAuthorizeRequest struct {
ResponseType string `form:"response_type" binding:"required"` // Must be "code"
ClientID string `form:"client_id" binding:"required"`
RedirectURI string `form:"redirect_uri" binding:"required"`
Scope string `form:"scope"` // Space-separated scopes
State string `form:"state" binding:"required"` // CSRF protection
CodeChallenge string `form:"code_challenge"` // PKCE
CodeChallengeMethod string `form:"code_challenge_method"` // S256 (recommended) or plain
}
// OAuthTokenRequest for the token endpoint
type OAuthTokenRequest struct {
GrantType string `form:"grant_type" binding:"required"` // authorization_code or refresh_token
Code string `form:"code"` // For authorization_code grant
RedirectURI string `form:"redirect_uri"` // For authorization_code grant
ClientID string `form:"client_id" binding:"required"`
ClientSecret string `form:"client_secret"` // For confidential clients
CodeVerifier string `form:"code_verifier"` // For PKCE
RefreshToken string `form:"refresh_token"` // For refresh_token grant
Scope string `form:"scope"` // For refresh_token grant (optional)
}
// OAuthTokenResponse for successful token requests
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"` // Always "Bearer"
ExpiresIn int `json:"expires_in"` // Seconds until expiration
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope,omitempty"`
}
// OAuthErrorResponse for OAuth errors (RFC 6749)
type OAuthErrorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
ErrorURI string `json:"error_uri,omitempty"`
}

View File

@@ -0,0 +1,187 @@
package models
import (
"time"
"github.com/google/uuid"
)
// SchoolRole defines roles within the school system
const (
SchoolRoleTeacher = "teacher"
SchoolRoleClassTeacher = "class_teacher"
SchoolRoleParent = "parent"
SchoolRoleParentRep = "parent_representative"
SchoolRoleStudent = "student"
SchoolRoleAdmin = "school_admin"
SchoolRolePrincipal = "principal"
SchoolRoleSecretary = "secretary"
)
// AttendanceStatus defines the status of student attendance
const (
AttendancePresent = "present"
AttendanceAbsent = "absent"
AttendanceAbsentExcused = "excused"
AttendanceAbsentUnexcused = "unexcused"
AttendanceLate = "late"
AttendanceLateExcused = "late_excused"
AttendancePending = "pending_confirmation"
)
// GradeType defines the type of grade
const (
GradeTypeExam = "exam" // Klassenarbeit/Klausur
GradeTypeTest = "test" // Test/Kurzarbeit
GradeTypeOral = "oral" // Mündlich
GradeTypeHomework = "homework" // Hausaufgabe
GradeTypeProject = "project" // Projekt
GradeTypeParticipation = "participation" // Mitarbeit
GradeTypeSemester = "semester" // Halbjahres-/Semesternote
GradeTypeFinal = "final" // Endnote/Zeugnisnote
)
// School represents a school/educational institution
type School struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
ShortName *string `json:"short_name,omitempty" db:"short_name"`
Type string `json:"type" db:"type"` // 'grundschule', 'hauptschule', 'realschule', 'gymnasium', 'gesamtschule', 'berufsschule'
Address *string `json:"address,omitempty" db:"address"`
City *string `json:"city,omitempty" db:"city"`
PostalCode *string `json:"postal_code,omitempty" db:"postal_code"`
State *string `json:"state,omitempty" db:"state"` // Bundesland
Country string `json:"country" db:"country"` // Default: DE
Phone *string `json:"phone,omitempty" db:"phone"`
Email *string `json:"email,omitempty" db:"email"`
Website *string `json:"website,omitempty" db:"website"`
MatrixServerName *string `json:"matrix_server_name,omitempty" db:"matrix_server_name"`
LogoURL *string `json:"logo_url,omitempty" db:"logo_url"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SchoolYear represents an academic year
type SchoolYear struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
Name string `json:"name" db:"name"` // e.g., "2024/2025"
StartDate time.Time `json:"start_date" db:"start_date"`
EndDate time.Time `json:"end_date" db:"end_date"`
IsCurrent bool `json:"is_current" db:"is_current"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Class represents a school class
type Class struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"`
Name string `json:"name" db:"name"` // e.g., "5a", "10b"
Grade int `json:"grade" db:"grade"` // Klassenstufe: 1-13
Section *string `json:"section,omitempty" db:"section"`
Room *string `json:"room,omitempty" db:"room"`
MatrixInfoRoom *string `json:"matrix_info_room,omitempty" db:"matrix_info_room"`
MatrixRepRoom *string `json:"matrix_rep_room,omitempty" db:"matrix_rep_room"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Subject represents a school subject
type Subject struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
Name string `json:"name" db:"name"` // e.g., "Mathematik", "Deutsch"
ShortName string `json:"short_name" db:"short_name"` // e.g., "Ma", "De"
Color *string `json:"color,omitempty" db:"color"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Student represents a student
type Student struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"`
StudentNumber *string `json:"student_number,omitempty" db:"student_number"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
DateOfBirth *time.Time `json:"date_of_birth,omitempty" db:"date_of_birth"`
Gender *string `json:"gender,omitempty" db:"gender"`
MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"`
MatrixDMRoom *string `json:"matrix_dm_room,omitempty" db:"matrix_dm_room"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Teacher represents a teacher
type Teacher struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
TeacherCode *string `json:"teacher_code,omitempty" db:"teacher_code"`
Title *string `json:"title,omitempty" db:"title"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ClassTeacher assigns teachers to classes (Klassenlehrer)
type ClassTeacher struct {
ID uuid.UUID `json:"id" db:"id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
IsPrimary bool `json:"is_primary" db:"is_primary"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TeacherSubject assigns subjects to teachers
type TeacherSubject struct {
ID uuid.UUID `json:"id" db:"id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Parent represents a parent/guardian
type Parent struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"`
FirstName string `json:"first_name" db:"first_name"`
LastName string `json:"last_name" db:"last_name"`
Phone *string `json:"phone,omitempty" db:"phone"`
EmergencyContact bool `json:"emergency_contact" db:"emergency_contact"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// StudentParent links students to their parents
type StudentParent struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
Relationship string `json:"relationship" db:"relationship"`
IsPrimary bool `json:"is_primary" db:"is_primary"`
HasCustody bool `json:"has_custody" db:"has_custody"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ParentRepresentative assigns parent representatives to classes
type ParentRepresentative struct {
ID uuid.UUID `json:"id" db:"id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
Role string `json:"role" db:"role"` // 'first_rep', 'second_rep', 'substitute'
ElectedAt time.Time `json:"elected_at" db:"elected_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@@ -0,0 +1,382 @@
package models
import (
"time"
"github.com/google/uuid"
)
// ========================================
// Stundenplan / Timetable
// ========================================
// TimetableSlot represents a time slot in the timetable
type TimetableSlot struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
SlotNumber int `json:"slot_number" db:"slot_number"` // 1, 2, 3... (Stunde)
StartTime string `json:"start_time" db:"start_time"` // "08:00"
EndTime string `json:"end_time" db:"end_time"` // "08:45"
IsBreak bool `json:"is_break" db:"is_break"` // Pause
Name *string `json:"name,omitempty" db:"name"` // e.g., "1. Stunde", "Große Pause"
}
// TimetableEntry represents a single lesson in the timetable
type TimetableEntry struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
SlotID uuid.UUID `json:"slot_id" db:"slot_id"`
DayOfWeek int `json:"day_of_week" db:"day_of_week"` // 1=Monday, 5=Friday
Room *string `json:"room,omitempty" db:"room"`
ValidFrom time.Time `json:"valid_from" db:"valid_from"`
ValidUntil *time.Time `json:"valid_until,omitempty" db:"valid_until"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TimetableSubstitution represents a substitution/replacement lesson
type TimetableSubstitution struct {
ID uuid.UUID `json:"id" db:"id"`
OriginalEntryID uuid.UUID `json:"original_entry_id" db:"original_entry_id"`
Date time.Time `json:"date" db:"date"`
SubstituteTeacherID *uuid.UUID `json:"substitute_teacher_id,omitempty" db:"substitute_teacher_id"`
SubstituteSubjectID *uuid.UUID `json:"substitute_subject_id,omitempty" db:"substitute_subject_id"`
Room *string `json:"room,omitempty" db:"room"`
Type string `json:"type" db:"type"` // 'substitution', 'cancelled', 'room_change', 'supervision'
Note *string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedBy uuid.UUID `json:"created_by" db:"created_by"`
}
// ========================================
// Abwesenheit / Attendance
// ========================================
// AttendanceRecord represents a student's attendance for a specific lesson
type AttendanceRecord struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
TimetableEntryID *uuid.UUID `json:"timetable_entry_id,omitempty" db:"timetable_entry_id"`
Date time.Time `json:"date" db:"date"`
SlotID uuid.UUID `json:"slot_id" db:"slot_id"`
Status string `json:"status" db:"status"` // AttendanceStatus constants
RecordedBy uuid.UUID `json:"recorded_by" db:"recorded_by"`
Note *string `json:"note,omitempty" db:"note"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// AbsenceReport represents a full absence report (one or more days)
type AbsenceReport struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
StartDate time.Time `json:"start_date" db:"start_date"`
EndDate time.Time `json:"end_date" db:"end_date"`
Reason *string `json:"reason,omitempty" db:"reason"`
ReasonCategory string `json:"reason_category" db:"reason_category"`
Status string `json:"status" db:"status"`
ReportedBy uuid.UUID `json:"reported_by" db:"reported_by"`
ReportedAt time.Time `json:"reported_at" db:"reported_at"`
ConfirmedBy *uuid.UUID `json:"confirmed_by,omitempty" db:"confirmed_by"`
ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"`
MedicalCertificate bool `json:"medical_certificate" db:"medical_certificate"`
CertificateUploaded bool `json:"certificate_uploaded" db:"certificate_uploaded"`
MatrixNotificationSent bool `json:"matrix_notification_sent" db:"matrix_notification_sent"`
EmailNotificationSent bool `json:"email_notification_sent" db:"email_notification_sent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// AbsenceNotification tracks notifications sent to parents about absences
type AbsenceNotification struct {
ID uuid.UUID `json:"id" db:"id"`
AttendanceRecordID uuid.UUID `json:"attendance_record_id" db:"attendance_record_id"`
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
Channel string `json:"channel" db:"channel"`
MessageContent string `json:"message_content" db:"message_content"`
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"`
ResponseReceived bool `json:"response_received" db:"response_received"`
ResponseContent *string `json:"response_content,omitempty" db:"response_content"`
ResponseAt *time.Time `json:"response_at,omitempty" db:"response_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ========================================
// Notenspiegel / Grades
// ========================================
// GradeScale represents the grading scale used
type GradeScale struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
Name string `json:"name" db:"name"`
MinValue float64 `json:"min_value" db:"min_value"`
MaxValue float64 `json:"max_value" db:"max_value"`
PassingValue float64 `json:"passing_value" db:"passing_value"`
IsAscending bool `json:"is_ascending" db:"is_ascending"`
IsDefault bool `json:"is_default" db:"is_default"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Grade represents a single grade for a student
type Grade struct {
ID uuid.UUID `json:"id" db:"id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"`
GradeScaleID uuid.UUID `json:"grade_scale_id" db:"grade_scale_id"`
Type string `json:"type" db:"type"`
Value float64 `json:"value" db:"value"`
Weight float64 `json:"weight" db:"weight"`
Date time.Time `json:"date" db:"date"`
Title *string `json:"title,omitempty" db:"title"`
Description *string `json:"description,omitempty" db:"description"`
IsVisible bool `json:"is_visible" db:"is_visible"`
Semester int `json:"semester" db:"semester"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// GradeComment represents a teacher comment on a student's grade
type GradeComment struct {
ID uuid.UUID `json:"id" db:"id"`
GradeID uuid.UUID `json:"grade_id" db:"grade_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
Comment string `json:"comment" db:"comment"`
IsPrivate bool `json:"is_private" db:"is_private"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ========================================
// Klassenbuch, Meetings, Communication
// ========================================
// ClassDiaryEntry represents an entry in the digital class diary
type ClassDiaryEntry struct {
ID uuid.UUID `json:"id" db:"id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
Date time.Time `json:"date" db:"date"`
SlotID uuid.UUID `json:"slot_id" db:"slot_id"`
SubjectID uuid.UUID `json:"subject_id" db:"subject_id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
Topic *string `json:"topic,omitempty" db:"topic"`
Homework *string `json:"homework,omitempty" db:"homework"`
HomeworkDueDate *time.Time `json:"homework_due_date,omitempty" db:"homework_due_date"`
Materials *string `json:"materials,omitempty" db:"materials"`
Notes *string `json:"notes,omitempty" db:"notes"`
IsCancelled bool `json:"is_cancelled" db:"is_cancelled"`
CancellationReason *string `json:"cancellation_reason,omitempty" db:"cancellation_reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ParentMeetingSlot represents available time slots for parent meetings
type ParentMeetingSlot struct {
ID uuid.UUID `json:"id" db:"id"`
TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"`
Date time.Time `json:"date" db:"date"`
StartTime string `json:"start_time" db:"start_time"`
EndTime string `json:"end_time" db:"end_time"`
Location *string `json:"location,omitempty" db:"location"`
IsOnline bool `json:"is_online" db:"is_online"`
MeetingLink *string `json:"meeting_link,omitempty" db:"meeting_link"`
IsBooked bool `json:"is_booked" db:"is_booked"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ParentMeeting represents a booked parent-teacher meeting
type ParentMeeting struct {
ID uuid.UUID `json:"id" db:"id"`
SlotID uuid.UUID `json:"slot_id" db:"slot_id"`
ParentID uuid.UUID `json:"parent_id" db:"parent_id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
Topic *string `json:"topic,omitempty" db:"topic"`
Notes *string `json:"notes,omitempty" db:"notes"`
Status string `json:"status" db:"status"`
CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at"`
CancelledBy *uuid.UUID `json:"cancelled_by,omitempty" db:"cancelled_by"`
CancelReason *string `json:"cancel_reason,omitempty" db:"cancel_reason"`
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// MatrixRoom tracks Matrix rooms created for school communication
type MatrixRoom struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
MatrixRoomID string `json:"matrix_room_id" db:"matrix_room_id"`
Type string `json:"type" db:"type"`
ClassID *uuid.UUID `json:"class_id,omitempty" db:"class_id"`
StudentID *uuid.UUID `json:"student_id,omitempty" db:"student_id"`
Name string `json:"name" db:"name"`
IsEncrypted bool `json:"is_encrypted" db:"is_encrypted"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// MatrixRoomMember tracks membership in Matrix rooms
type MatrixRoomMember struct {
ID uuid.UUID `json:"id" db:"id"`
MatrixRoomID uuid.UUID `json:"matrix_room_id" db:"matrix_room_id"`
MatrixUserID string `json:"matrix_user_id" db:"matrix_user_id"`
UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"`
PowerLevel int `json:"power_level" db:"power_level"`
CanWrite bool `json:"can_write" db:"can_write"`
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
LeftAt *time.Time `json:"left_at,omitempty" db:"left_at"`
}
// ParentOnboardingToken for QR-code based parent onboarding
type ParentOnboardingToken struct {
ID uuid.UUID `json:"id" db:"id"`
SchoolID uuid.UUID `json:"school_id" db:"school_id"`
ClassID uuid.UUID `json:"class_id" db:"class_id"`
StudentID uuid.UUID `json:"student_id" db:"student_id"`
Token string `json:"token" db:"token"`
Role string `json:"role" db:"role"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
UsedByUserID *uuid.UUID `json:"used_by_user_id,omitempty" db:"used_by_user_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedBy uuid.UUID `json:"created_by" db:"created_by"`
}
// ========================================
// Schulverwaltung DTOs
// ========================================
// CreateSchoolRequest for creating a new school
type CreateSchoolRequest struct {
Name string `json:"name" binding:"required"`
ShortName *string `json:"short_name"`
Type string `json:"type" binding:"required"`
Address *string `json:"address"`
City *string `json:"city"`
PostalCode *string `json:"postal_code"`
State *string `json:"state"`
Phone *string `json:"phone"`
Email *string `json:"email"`
Website *string `json:"website"`
}
// CreateClassRequest for creating a new class
type CreateClassRequest struct {
SchoolYearID string `json:"school_year_id" binding:"required"`
Name string `json:"name" binding:"required"`
Grade int `json:"grade" binding:"required"`
Section *string `json:"section"`
Room *string `json:"room"`
}
// CreateStudentRequest for creating a new student
type CreateStudentRequest struct {
ClassID string `json:"class_id" binding:"required"`
StudentNumber *string `json:"student_number"`
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
DateOfBirth *string `json:"date_of_birth"` // ISO 8601
Gender *string `json:"gender"`
}
// RecordAttendanceRequest for recording attendance
type RecordAttendanceRequest struct {
StudentID string `json:"student_id" binding:"required"`
Date string `json:"date" binding:"required"` // ISO 8601
SlotID string `json:"slot_id" binding:"required"`
Status string `json:"status" binding:"required"` // AttendanceStatus
Note *string `json:"note"`
}
// ReportAbsenceRequest for parents reporting absence
type ReportAbsenceRequest struct {
StudentID string `json:"student_id" binding:"required"`
StartDate string `json:"start_date" binding:"required"`
EndDate string `json:"end_date" binding:"required"`
Reason *string `json:"reason"`
ReasonCategory string `json:"reason_category" binding:"required"`
}
// CreateGradeRequest for creating a grade
type CreateGradeRequest struct {
StudentID string `json:"student_id" binding:"required"`
SubjectID string `json:"subject_id" binding:"required"`
SchoolYearID string `json:"school_year_id" binding:"required"`
Type string `json:"type" binding:"required"`
Value float64 `json:"value" binding:"required"`
Weight float64 `json:"weight"`
Date string `json:"date" binding:"required"`
Title *string `json:"title"`
Description *string `json:"description"`
Semester int `json:"semester" binding:"required"`
}
// StudentGradeOverview provides a summary of all grades for a student in a subject
type StudentGradeOverview struct {
Student Student `json:"student"`
Subject Subject `json:"subject"`
Grades []Grade `json:"grades"`
Average float64 `json:"average"`
OralAverage float64 `json:"oral_average"`
ExamAverage float64 `json:"exam_average"`
Semester int `json:"semester"`
}
// ClassAttendanceOverview provides attendance summary for a class
type ClassAttendanceOverview struct {
Class Class `json:"class"`
Date time.Time `json:"date"`
TotalStudents int `json:"total_students"`
PresentCount int `json:"present_count"`
AbsentCount int `json:"absent_count"`
LateCount int `json:"late_count"`
Records []AttendanceRecord `json:"records"`
}
// ParentDashboard provides a parent's view of their children's data
type ParentDashboard struct {
Children []StudentOverview `json:"children"`
UnreadMessages int `json:"unread_messages"`
UpcomingMeetings []ParentMeeting `json:"upcoming_meetings"`
RecentGrades []Grade `json:"recent_grades"`
PendingActions []string `json:"pending_actions"`
}
// StudentOverview provides summary info about a student
type StudentOverview struct {
Student Student `json:"student"`
Class Class `json:"class"`
ClassTeacher *Teacher `json:"class_teacher,omitempty"`
AttendanceRate float64 `json:"attendance_rate"`
UnexcusedAbsences int `json:"unexcused_absences"`
GradeAverage float64 `json:"grade_average"`
}
// TimetableView provides a formatted timetable for display
type TimetableView struct {
Class Class `json:"class"`
Week string `json:"week"` // ISO week: "2025-W01"
Days []TimetableDayView `json:"days"`
}
// TimetableDayView represents a single day in the timetable
type TimetableDayView struct {
Date time.Time `json:"date"`
DayName string `json:"day_name"`
Lessons []TimetableLessonView `json:"lessons"`
}
// TimetableLessonView represents a single lesson in the timetable view
type TimetableLessonView struct {
Slot TimetableSlot `json:"slot"`
Subject *Subject `json:"subject,omitempty"`
Teacher *Teacher `json:"teacher,omitempty"`
Room *string `json:"room,omitempty"`
IsSubstitution bool `json:"is_substitution"`
IsCancelled bool `json:"is_cancelled"`
Note *string `json:"note,omitempty"`
}

View File

@@ -0,0 +1,195 @@
package models
import (
"time"
"github.com/google/uuid"
)
// User represents a user with full authentication support
type User struct {
ID uuid.UUID `json:"id" db:"id"`
ExternalID *string `json:"external_id,omitempty" db:"external_id"`
Email string `json:"email" db:"email"`
PasswordHash *string `json:"-" db:"password_hash"` // Never exposed in JSON
Name *string `json:"name,omitempty" db:"name"`
Role string `json:"role" db:"role"` // 'user', 'admin', 'super_admin', 'data_protection_officer'
EmailVerified bool `json:"email_verified" db:"email_verified"`
EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty" db:"email_verified_at"`
AccountStatus string `json:"account_status" db:"account_status"` // 'active', 'suspended', 'locked'
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
FailedLoginAttempts int `json:"failed_login_attempts" db:"failed_login_attempts"`
LockedUntil *time.Time `json:"locked_until,omitempty" db:"locked_until"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// EmailVerificationToken for email verification
type EmailVerificationToken struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Token string `json:"token" db:"token"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// PasswordResetToken for password reset
type PasswordResetToken struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Token string `json:"token" db:"token"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
IPAddress *string `json:"ip_address,omitempty" db:"ip_address"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// UserSession for session management
type UserSession struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
TokenHash string `json:"-" db:"token_hash"`
DeviceInfo *string `json:"device_info,omitempty" db:"device_info"`
IPAddress *string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
LastActivityAt time.Time `json:"last_activity_at" db:"last_activity_at"`
}
// RegisterRequest for user registration
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name *string `json:"name"`
}
// LoginRequest for user login
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
// LoginResponse after successful login
type LoginResponse struct {
User User `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // seconds
}
// RefreshTokenRequest for token refresh
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// VerifyEmailRequest for email verification
type VerifyEmailRequest struct {
Token string `json:"token" binding:"required"`
}
// ForgotPasswordRequest for password reset request
type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
}
// ResetPasswordRequest for password reset
type ResetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// ChangePasswordRequest for changing password
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// UpdateProfileRequest for profile updates
type UpdateProfileRequest struct {
Name *string `json:"name"`
}
// ========================================
// Two-Factor Authentication (2FA/TOTP)
// ========================================
// UserTOTP stores 2FA TOTP configuration for a user
type UserTOTP struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Secret string `json:"-" db:"secret"` // Encrypted TOTP secret
Verified bool `json:"verified" db:"verified"` // Has 2FA been verified/activated
RecoveryCodes []string `json:"-" db:"recovery_codes"` // Encrypted backup codes
EnabledAt *time.Time `json:"enabled_at,omitempty" db:"enabled_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TwoFactorChallenge represents a pending 2FA challenge during login
type TwoFactorChallenge struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
ChallengeID string `json:"challenge_id" db:"challenge_id"` // Temporary token
IPAddress *string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Setup2FAResponse when initiating 2FA setup
type Setup2FAResponse struct {
Secret string `json:"secret"` // Base32 encoded secret for manual entry
QRCodeDataURL string `json:"qr_code"` // Data URL for QR code image
RecoveryCodes []string `json:"recovery_codes"` // One-time backup codes
}
// Verify2FARequest for verifying 2FA setup or login
type Verify2FARequest struct {
Code string `json:"code" binding:"required"` // 6-digit TOTP code
ChallengeID string `json:"challenge_id,omitempty"` // For login flow
}
// TwoFactorLoginResponse when 2FA is required during login
type TwoFactorLoginResponse struct {
RequiresTwoFactor bool `json:"requires_two_factor"`
ChallengeID string `json:"challenge_id"` // Use this to complete 2FA
Message string `json:"message"`
}
// Complete2FALoginRequest to complete login with 2FA
type Complete2FALoginRequest struct {
ChallengeID string `json:"challenge_id" binding:"required"`
Code string `json:"code" binding:"required"` // 6-digit TOTP or recovery code
}
// Disable2FARequest for disabling 2FA
type Disable2FARequest struct {
Password string `json:"password" binding:"required"` // Require password confirmation
Code string `json:"code" binding:"required"` // Current TOTP code
}
// RecoveryCodeUseRequest for using a recovery code
type RecoveryCodeUseRequest struct {
ChallengeID string `json:"challenge_id" binding:"required"`
RecoveryCode string `json:"recovery_code" binding:"required"`
}
// TwoFactorStatusResponse for checking 2FA status
type TwoFactorStatusResponse struct {
Enabled bool `json:"enabled"`
Verified bool `json:"verified"`
EnabledAt *time.Time `json:"enabled_at,omitempty"`
RecoveryCodesCount int `json:"recovery_codes_count"`
}
// Verify2FAChallengeRequest for verifying a 2FA challenge during login
type Verify2FAChallengeRequest struct {
ChallengeID string `json:"challenge_id" binding:"required"`
Code string `json:"code,omitempty"` // 6-digit TOTP code
RecoveryCode string `json:"recovery_code,omitempty"` // Alternative: recovery code
}

View File

@@ -235,271 +235,3 @@ func (s *AttendanceService) GetStudentAttendance(ctx context.Context, studentID
return records, nil
}
// ========================================
// Absence Reports (Parent-initiated)
// ========================================
// ReportAbsence allows parents to report a student's absence
func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) {
studentID, err := uuid.Parse(req.StudentID)
if err != nil {
return nil, fmt.Errorf("invalid student ID: %w", err)
}
startDate, err := time.Parse("2006-01-02", req.StartDate)
if err != nil {
return nil, fmt.Errorf("invalid start date format: %w", err)
}
endDate, err := time.Parse("2006-01-02", req.EndDate)
if err != nil {
return nil, fmt.Errorf("invalid end date format: %w", err)
}
report := &models.AbsenceReport{
ID: uuid.New(),
StudentID: studentID,
StartDate: startDate,
EndDate: endDate,
Reason: req.Reason,
ReasonCategory: req.ReasonCategory,
Status: "reported",
ReportedBy: reportedByUserID,
ReportedAt: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id`
err = s.db.Pool.QueryRow(ctx, query,
report.ID, report.StudentID, report.StartDate, report.EndDate,
report.Reason, report.ReasonCategory, report.Status,
report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt,
).Scan(&report.ID)
if err != nil {
return nil, fmt.Errorf("failed to create absence report: %w", err)
}
return report, nil
}
// ConfirmAbsence allows teachers to confirm/excuse an absence
func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error {
query := `
UPDATE absence_reports
SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW()
WHERE id = $3`
result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID)
if err != nil {
return fmt.Errorf("failed to confirm absence: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("absence report not found")
}
return nil
}
// GetAbsenceReports gets absence reports for a student
func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) {
query := `
SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at
FROM absence_reports
WHERE student_id = $1
ORDER BY start_date DESC`
rows, err := s.db.Pool.Query(ctx, query, studentID)
if err != nil {
return nil, fmt.Errorf("failed to get absence reports: %w", err)
}
defer rows.Close()
var reports []models.AbsenceReport
for rows.Next() {
var report models.AbsenceReport
err := rows.Scan(
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
&report.Reason, &report.ReasonCategory, &report.Status,
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
&report.MedicalCertificate, &report.CertificateUploaded,
&report.MatrixNotificationSent, &report.EmailNotificationSent,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan absence report: %w", err)
}
reports = append(reports, report)
}
return reports, nil
}
// GetPendingAbsenceReports gets all unconfirmed absence reports for a class
func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) {
query := `
SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at
FROM absence_reports ar
JOIN students s ON ar.student_id = s.id
WHERE s.class_id = $1 AND ar.status = 'reported'
ORDER BY ar.start_date DESC`
rows, err := s.db.Pool.Query(ctx, query, classID)
if err != nil {
return nil, fmt.Errorf("failed to get pending absence reports: %w", err)
}
defer rows.Close()
var reports []models.AbsenceReport
for rows.Next() {
var report models.AbsenceReport
err := rows.Scan(
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
&report.Reason, &report.ReasonCategory, &report.Status,
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
&report.MedicalCertificate, &report.CertificateUploaded,
&report.MatrixNotificationSent, &report.EmailNotificationSent,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan absence report: %w", err)
}
reports = append(reports, report)
}
return reports, nil
}
// ========================================
// Attendance Statistics
// ========================================
// GetStudentAttendanceStats gets attendance statistics for a student
func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) {
query := `
SELECT
COUNT(*) as total_records,
COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count,
COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count,
COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count,
COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count
FROM attendance_records ar
JOIN timetable_slots ts ON ar.slot_id = ts.id
JOIN schools sch ON ts.school_id = sch.id
JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2
WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date`
var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan(
&totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount,
)
if err != nil {
return nil, fmt.Errorf("failed to get attendance stats: %w", err)
}
var attendanceRate float64
if totalRecords > 0 {
attendanceRate = float64(presentCount) / float64(totalRecords) * 100
}
return map[string]interface{}{
"total_records": totalRecords,
"present_count": presentCount,
"absent_count": absentCount,
"unexcused_count": unexcusedCount,
"late_count": lateCount,
"attendance_rate": attendanceRate,
}, nil
}
// ========================================
// Parent Notifications
// ========================================
func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) {
if s.matrix == nil {
return
}
// Get student info
var studentFirstName, studentLastName, matrixDMRoom string
err := s.db.Pool.QueryRow(ctx, `
SELECT first_name, last_name, matrix_dm_room
FROM students
WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
if err != nil || matrixDMRoom == "" {
return
}
// Get slot info
var slotNumber int
err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber)
if err != nil {
return
}
studentName := studentFirstName + " " + studentLastName
dateStr := record.Date.Format("02.01.2006")
// Send Matrix notification
err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber)
if err != nil {
fmt.Printf("Failed to send absence notification: %v\n", err)
return
}
// Update notification status
s.db.Pool.Exec(ctx, `
UPDATE attendance_records
SET updated_at = NOW()
WHERE id = $1`, record.ID)
// Log the notification
s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber)
}
func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) {
record := &models.AttendanceRecord{
StudentID: studentID,
Date: date,
SlotID: slotID,
}
s.notifyParentsOfAbsence(ctx, record)
}
func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) {
// Get parent IDs for this student
query := `
SELECT p.id
FROM parents p
JOIN student_parents sp ON p.id = sp.parent_id
JOIN attendance_records ar ON sp.student_id = ar.student_id
WHERE ar.id = $1`
rows, err := s.db.Pool.Query(ctx, query, recordID)
if err != nil {
return
}
defer rows.Close()
message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber)
for rows.Next() {
var parentID uuid.UUID
if err := rows.Scan(&parentID); err != nil {
continue
}
// Insert notification log
s.db.Pool.Exec(ctx, `
INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at)
VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`,
uuid.New(), recordID, parentID, message)
}
}

View File

@@ -0,0 +1,280 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// ========================================
// Absence Reports (Parent-initiated)
// ========================================
// ReportAbsence allows parents to report a student's absence
func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) {
studentID, err := uuid.Parse(req.StudentID)
if err != nil {
return nil, fmt.Errorf("invalid student ID: %w", err)
}
startDate, err := time.Parse("2006-01-02", req.StartDate)
if err != nil {
return nil, fmt.Errorf("invalid start date format: %w", err)
}
endDate, err := time.Parse("2006-01-02", req.EndDate)
if err != nil {
return nil, fmt.Errorf("invalid end date format: %w", err)
}
report := &models.AbsenceReport{
ID: uuid.New(),
StudentID: studentID,
StartDate: startDate,
EndDate: endDate,
Reason: req.Reason,
ReasonCategory: req.ReasonCategory,
Status: "reported",
ReportedBy: reportedByUserID,
ReportedAt: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id`
err = s.db.Pool.QueryRow(ctx, query,
report.ID, report.StudentID, report.StartDate, report.EndDate,
report.Reason, report.ReasonCategory, report.Status,
report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt,
).Scan(&report.ID)
if err != nil {
return nil, fmt.Errorf("failed to create absence report: %w", err)
}
return report, nil
}
// ConfirmAbsence allows teachers to confirm/excuse an absence
func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error {
query := `
UPDATE absence_reports
SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW()
WHERE id = $3`
result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID)
if err != nil {
return fmt.Errorf("failed to confirm absence: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("absence report not found")
}
return nil
}
// GetAbsenceReports gets absence reports for a student
func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) {
query := `
SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at
FROM absence_reports
WHERE student_id = $1
ORDER BY start_date DESC`
rows, err := s.db.Pool.Query(ctx, query, studentID)
if err != nil {
return nil, fmt.Errorf("failed to get absence reports: %w", err)
}
defer rows.Close()
var reports []models.AbsenceReport
for rows.Next() {
var report models.AbsenceReport
err := rows.Scan(
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
&report.Reason, &report.ReasonCategory, &report.Status,
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
&report.MedicalCertificate, &report.CertificateUploaded,
&report.MatrixNotificationSent, &report.EmailNotificationSent,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan absence report: %w", err)
}
reports = append(reports, report)
}
return reports, nil
}
// GetPendingAbsenceReports gets all unconfirmed absence reports for a class
func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) {
query := `
SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at
FROM absence_reports ar
JOIN students s ON ar.student_id = s.id
WHERE s.class_id = $1 AND ar.status = 'reported'
ORDER BY ar.start_date DESC`
rows, err := s.db.Pool.Query(ctx, query, classID)
if err != nil {
return nil, fmt.Errorf("failed to get pending absence reports: %w", err)
}
defer rows.Close()
var reports []models.AbsenceReport
for rows.Next() {
var report models.AbsenceReport
err := rows.Scan(
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
&report.Reason, &report.ReasonCategory, &report.Status,
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
&report.MedicalCertificate, &report.CertificateUploaded,
&report.MatrixNotificationSent, &report.EmailNotificationSent,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan absence report: %w", err)
}
reports = append(reports, report)
}
return reports, nil
}
// ========================================
// Attendance Statistics
// ========================================
// GetStudentAttendanceStats gets attendance statistics for a student
func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) {
query := `
SELECT
COUNT(*) as total_records,
COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count,
COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count,
COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count,
COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count
FROM attendance_records ar
JOIN timetable_slots ts ON ar.slot_id = ts.id
JOIN schools sch ON ts.school_id = sch.id
JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2
WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date`
var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan(
&totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount,
)
if err != nil {
return nil, fmt.Errorf("failed to get attendance stats: %w", err)
}
var attendanceRate float64
if totalRecords > 0 {
attendanceRate = float64(presentCount) / float64(totalRecords) * 100
}
return map[string]interface{}{
"total_records": totalRecords,
"present_count": presentCount,
"absent_count": absentCount,
"unexcused_count": unexcusedCount,
"late_count": lateCount,
"attendance_rate": attendanceRate,
}, nil
}
// ========================================
// Parent Notifications
// ========================================
func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) {
if s.matrix == nil {
return
}
// Get student info
var studentFirstName, studentLastName, matrixDMRoom string
err := s.db.Pool.QueryRow(ctx, `
SELECT first_name, last_name, matrix_dm_room
FROM students
WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
if err != nil || matrixDMRoom == "" {
return
}
// Get slot info
var slotNumber int
err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber)
if err != nil {
return
}
studentName := studentFirstName + " " + studentLastName
dateStr := record.Date.Format("02.01.2006")
// Send Matrix notification
err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber)
if err != nil {
fmt.Printf("Failed to send absence notification: %v\n", err)
return
}
// Update notification status
s.db.Pool.Exec(ctx, `
UPDATE attendance_records
SET updated_at = NOW()
WHERE id = $1`, record.ID)
// Log the notification
s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber)
}
func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) {
record := &models.AttendanceRecord{
StudentID: studentID,
Date: date,
SlotID: slotID,
}
s.notifyParentsOfAbsence(ctx, record)
}
func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) {
// Get parent IDs for this student
query := `
SELECT p.id
FROM parents p
JOIN student_parents sp ON p.id = sp.parent_id
JOIN attendance_records ar ON sp.student_id = ar.student_id
WHERE ar.id = $1`
rows, err := s.db.Pool.Query(ctx, query, recordID)
if err != nil {
return
}
defer rows.Close()
message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber)
for rows.Next() {
var parentID uuid.UUID
if err := rows.Scan(&parentID); err != nil {
continue
}
// Insert notification log
s.db.Pool.Exec(ctx, `
INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at)
VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`,
uuid.New(), recordID, parentID, message)
}
}

View File

@@ -383,186 +383,3 @@ func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
return nil
}
// CreatePasswordResetToken creates a password reset token
func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) {
var userID uuid.UUID
err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID)
if err != nil {
// Don't reveal if user exists
return "", nil, nil
}
token, err := s.GenerateSecureToken(32)
if err != nil {
return "", nil, err
}
_, err = s.db.Exec(ctx, `
INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at)
VALUES ($1, $2, $3, $4, NOW())
`, userID, token, time.Now().Add(time.Hour), ipAddress)
if err != nil {
return "", nil, fmt.Errorf("failed to create reset token: %w", err)
}
return token, &userID, nil
}
// ResetPassword resets a user's password using a reset token
func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error {
var tokenID uuid.UUID
var userID uuid.UUID
var expiresAt time.Time
var usedAt *time.Time
err := s.db.QueryRow(ctx, `
SELECT id, user_id, expires_at, used_at FROM password_reset_tokens
WHERE token = $1
`, token).Scan(&tokenID, &userID, &expiresAt, &usedAt)
if err != nil {
return ErrInvalidToken
}
if usedAt != nil || expiresAt.Before(time.Now()) {
return ErrInvalidToken
}
// Hash new password
passwordHash, err := s.HashPassword(newPassword)
if err != nil {
return err
}
// Mark token as used
_, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID)
if err != nil {
return fmt.Errorf("failed to update token: %w", err)
}
// Update password
_, err = s.db.Exec(ctx, `
UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2
`, passwordHash, userID)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// Revoke all sessions for security
_, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
if err != nil {
fmt.Printf("Warning: failed to revoke sessions: %v\n", err)
}
return nil
}
// ChangePassword changes a user's password (requires current password)
func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
var passwordHash *string
err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash)
if err != nil {
return ErrUserNotFound
}
if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) {
return ErrInvalidCredentials
}
newPasswordHash, err := s.HashPassword(newPassword)
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}
// GetUserByID retrieves a user by ID
func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) {
var user models.User
err := s.db.QueryRow(ctx, `
SELECT id, email, name, role, email_verified, email_verified_at, account_status,
last_login_at, created_at, updated_at
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt,
&user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, ErrUserNotFound
}
return &user, nil
}
// UpdateProfile updates a user's profile
func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) {
_, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID)
if err != nil {
return nil, fmt.Errorf("failed to update profile: %w", err)
}
return s.GetUserByID(ctx, userID)
}
// GetActiveSessions retrieves all active sessions for a user
func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) {
rows, err := s.db.Query(ctx, `
SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at
FROM user_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY last_activity_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to get sessions: %w", err)
}
defer rows.Close()
var sessions []models.UserSession
for rows.Next() {
var session models.UserSession
err := rows.Scan(
&session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress,
&session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan session: %w", err)
}
sessions = append(sessions, session)
}
return sessions, nil
}
// RevokeSession revokes a specific session
func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
result, err := s.db.Exec(ctx, `
UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`, sessionID, userID)
if err != nil {
return fmt.Errorf("failed to revoke session: %w", err)
}
if result.RowsAffected() == 0 {
return errors.New("session not found")
}
return nil
}
// Logout revokes a session by refresh token
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
tokenHash := s.HashToken(refreshToken)
_, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash)
return err
}

View File

@@ -0,0 +1,196 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/breakpilot/consent-service/internal/models"
)
// CreatePasswordResetToken creates a password reset token
func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) {
var userID uuid.UUID
err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID)
if err != nil {
// Don't reveal if user exists
return "", nil, nil
}
token, err := s.GenerateSecureToken(32)
if err != nil {
return "", nil, err
}
_, err = s.db.Exec(ctx, `
INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at)
VALUES ($1, $2, $3, $4, NOW())
`, userID, token, time.Now().Add(time.Hour), ipAddress)
if err != nil {
return "", nil, fmt.Errorf("failed to create reset token: %w", err)
}
return token, &userID, nil
}
// ResetPassword resets a user's password using a reset token
func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error {
var tokenID uuid.UUID
var userID uuid.UUID
var expiresAt time.Time
var usedAt *time.Time
err := s.db.QueryRow(ctx, `
SELECT id, user_id, expires_at, used_at FROM password_reset_tokens
WHERE token = $1
`, token).Scan(&tokenID, &userID, &expiresAt, &usedAt)
if err != nil {
return ErrInvalidToken
}
if usedAt != nil || expiresAt.Before(time.Now()) {
return ErrInvalidToken
}
// Hash new password
passwordHash, err := s.HashPassword(newPassword)
if err != nil {
return err
}
// Mark token as used
_, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID)
if err != nil {
return fmt.Errorf("failed to update token: %w", err)
}
// Update password
_, err = s.db.Exec(ctx, `
UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2
`, passwordHash, userID)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// Revoke all sessions for security
_, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
if err != nil {
fmt.Printf("Warning: failed to revoke sessions: %v\n", err)
}
return nil
}
// ChangePassword changes a user's password (requires current password)
func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
var passwordHash *string
err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash)
if err != nil {
return ErrUserNotFound
}
if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) {
return ErrInvalidCredentials
}
newPasswordHash, err := s.HashPassword(newPassword)
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}
// GetUserByID retrieves a user by ID
func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) {
var user models.User
err := s.db.QueryRow(ctx, `
SELECT id, email, name, role, email_verified, email_verified_at, account_status,
last_login_at, created_at, updated_at
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt,
&user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, ErrUserNotFound
}
return &user, nil
}
// UpdateProfile updates a user's profile
func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) {
_, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID)
if err != nil {
return nil, fmt.Errorf("failed to update profile: %w", err)
}
return s.GetUserByID(ctx, userID)
}
// GetActiveSessions retrieves all active sessions for a user
func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) {
rows, err := s.db.Query(ctx, `
SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at
FROM user_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY last_activity_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to get sessions: %w", err)
}
defer rows.Close()
var sessions []models.UserSession
for rows.Next() {
var session models.UserSession
err := rows.Scan(
&session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress,
&session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan session: %w", err)
}
sessions = append(sessions, session)
}
return sessions, nil
}
// RevokeSession revokes a specific session
func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
result, err := s.db.Exec(ctx, `
UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`, sessionID, userID)
if err != nil {
return fmt.Errorf("failed to revoke session: %w", err)
}
if result.RowsAffected() == 0 {
return errors.New("session not found")
}
return nil
}
// Logout revokes a session by refresh token
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
tokenHash := s.HashToken(refreshToken)
_, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash)
return err
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/breakpilot/consent-service/internal/models"
@@ -367,29 +366,6 @@ func (s *DSRService) AssignRequest(ctx context.Context, id uuid.UUID, assigneeID
return nil
}
// ExtendDeadline extends the deadline for a DSR
func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error {
// Default extension is 2 months (60 days) per Art. 12(3)
if days <= 0 {
days = 60
}
_, err := s.pool.Exec(ctx, `
UPDATE data_subject_requests
SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL,
extension_reason = $2,
updated_at = NOW()
WHERE id = $3
`, days, reason, id)
if err != nil {
return fmt.Errorf("failed to extend deadline: %w", err)
}
s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason))
return nil
}
// CompleteRequest marks a DSR as completed
func (s *DSRService) CompleteRequest(ctx context.Context, id uuid.UUID, summary string, resultData map[string]interface{}, completedBy uuid.UUID) error {
resultJSON, _ := json.Marshal(resultData)
@@ -470,352 +446,7 @@ func (s *DSRService) CancelRequest(ctx context.Context, id uuid.UUID, cancelledB
return nil
}
// GetDashboardStats returns statistics for the admin dashboard
func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) {
stats := &models.DSRDashboardStats{
ByType: make(map[string]int),
ByStatus: make(map[string]int),
}
// Total requests
s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests)
// Pending requests (not completed, rejected, or cancelled)
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM data_subject_requests
WHERE status NOT IN ('completed', 'rejected', 'cancelled')
`).Scan(&stats.PendingRequests)
// Overdue requests
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) < NOW()
AND status NOT IN ('completed', 'rejected', 'cancelled')
`).Scan(&stats.OverdueRequests)
// Completed this month
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM data_subject_requests
WHERE status = 'completed'
AND completed_at >= DATE_TRUNC('month', NOW())
`).Scan(&stats.CompletedThisMonth)
// Average processing days
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0)
FROM data_subject_requests WHERE status = 'completed'
`).Scan(&stats.AverageProcessingDays)
// Count by type
rows, _ := s.pool.Query(ctx, `
SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type
`)
for rows.Next() {
var t string
var count int
rows.Scan(&t, &count)
stats.ByType[t] = count
}
rows.Close()
// Count by status
rows, _ = s.pool.Query(ctx, `
SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status
`)
for rows.Next() {
var s string
var count int
rows.Scan(&s, &count)
stats.ByStatus[s] = count
}
rows.Close()
// Upcoming deadlines (next 7 days)
rows, _ = s.pool.Query(ctx, `
SELECT id, request_number, request_type, status, requester_email, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days'
AND status NOT IN ('completed', 'rejected', 'cancelled')
ORDER BY deadline_at ASC LIMIT 10
`)
for rows.Next() {
var dsr models.DataSubjectRequest
rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt)
stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr)
}
rows.Close()
return stats, nil
}
// GetStatusHistory retrieves the status history for a DSR
func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at
FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC
`, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query status history: %w", err)
}
defer rows.Close()
var history []models.DSRStatusHistory
for rows.Next() {
var h models.DSRStatusHistory
var metadataJSON []byte
err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt)
if err != nil {
continue
}
json.Unmarshal(metadataJSON, &h.Metadata)
history = append(history, h)
}
return history, nil
}
// GetCommunications retrieves communications for a DSR
func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, direction, channel, communication_type, template_version_id,
subject, body_html, body_text, recipient_email, sent_at, error_message,
attachments, created_at, created_by
FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC
`, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query communications: %w", err)
}
defer rows.Close()
var comms []models.DSRCommunication
for rows.Next() {
var c models.DSRCommunication
var attachmentsJSON []byte
err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType,
&c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail,
&c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy)
if err != nil {
continue
}
json.Unmarshal(attachmentsJSON, &c.Attachments)
comms = append(comms, c)
}
return comms, nil
}
// SendCommunication sends a communication for a DSR
func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error {
// Get DSR details
dsr, err := s.GetByID(ctx, requestID)
if err != nil {
return err
}
// Get template if specified
var subject, bodyHTML, bodyText string
if req.TemplateVersionID != nil {
templateVersionID, _ := uuid.Parse(*req.TemplateVersionID)
err := s.pool.QueryRow(ctx, `
SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published'
`, templateVersionID).Scan(&subject, &bodyHTML, &bodyText)
if err != nil {
return fmt.Errorf("template version not found or not published: %w", err)
}
}
// Use custom content if provided
if req.CustomSubject != nil {
subject = *req.CustomSubject
}
if req.CustomBody != nil {
bodyHTML = *req.CustomBody
bodyText = stripHTML(*req.CustomBody)
}
// Replace variables
variables := map[string]string{
"requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"),
"request_number": dsr.RequestNumber,
"request_type_de": dsr.RequestType.Label(),
"request_date": dsr.CreatedAt.Format("02.01.2006"),
"deadline_date": dsr.DeadlineAt.Format("02.01.2006"),
}
for k, v := range req.Variables {
variables[k] = v
}
subject = replaceVariables(subject, variables)
bodyHTML = replaceVariables(bodyHTML, variables)
bodyText = replaceVariables(bodyText, variables)
// Send email
if s.emailService != nil {
err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText)
if err != nil {
// Log error but continue
_, _ = s.pool.Exec(ctx, `
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by)
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
dsr.RequesterEmail, err.Error(), sentBy)
return fmt.Errorf("failed to send email: %w", err)
}
}
// Log communication
now := time.Now()
_, err = s.pool.Exec(ctx, `
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by)
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
dsr.RequesterEmail, now, sentBy)
return err
}
// InitErasureExceptionChecks initializes exception checks for an erasure request
func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error {
exceptions := []struct {
Type string
Description string
}{
{models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"},
{models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"},
{models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"},
{models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"},
{models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"},
}
for _, exc := range exceptions {
_, err := s.pool.Exec(ctx, `
INSERT INTO dsr_exception_checks (request_id, exception_type, description)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
`, requestID, exc.Type, exc.Description)
if err != nil {
return fmt.Errorf("failed to create exception check: %w", err)
}
}
return nil
}
// GetExceptionChecks retrieves exception checks for a DSR
func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at
FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at
`, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query exception checks: %w", err)
}
defer rows.Close()
var checks []models.DSRExceptionCheck
for rows.Next() {
var c models.DSRExceptionCheck
err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies,
&c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt)
if err != nil {
continue
}
checks = append(checks, c)
}
return checks, nil
}
// UpdateExceptionCheck updates an exception check
func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsr_exception_checks
SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW()
WHERE id = $4
`, applies, notes, checkedBy, checkID)
return err
}
// ProcessDeadlines checks for approaching and overdue deadlines
func (s *DSRService) ProcessDeadlines(ctx context.Context) error {
now := time.Now()
// Find requests with deadlines in 3 days
threeDaysAhead := now.AddDate(0, 0, 3)
rows, _ := s.pool.Query(ctx, `
SELECT id, request_number, request_type, assigned_to, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
AND status NOT IN ('completed', 'rejected', 'cancelled')
`, now, threeDaysAhead)
for rows.Next() {
var id uuid.UUID
var requestNumber, requestType string
var assignedTo *uuid.UUID
var deadline time.Time
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
// Notify assigned user or all DPOs
if assignedTo != nil {
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3)
} else {
s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline)
}
}
rows.Close()
// Find requests with deadlines in 1 day
oneDayAhead := now.AddDate(0, 0, 1)
rows, _ = s.pool.Query(ctx, `
SELECT id, request_number, request_type, assigned_to, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
AND status NOT IN ('completed', 'rejected', 'cancelled')
`, now, oneDayAhead)
for rows.Next() {
var id uuid.UUID
var requestNumber, requestType string
var assignedTo *uuid.UUID
var deadline time.Time
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
if assignedTo != nil {
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1)
} else {
s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline)
}
}
rows.Close()
// Find overdue requests
rows, _ = s.pool.Query(ctx, `
SELECT id, request_number, request_type, assigned_to, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) < $1
AND status NOT IN ('completed', 'rejected', 'cancelled')
`, now)
for rows.Next() {
var id uuid.UUID
var requestNumber, requestType string
var assignedTo *uuid.UUID
var deadline time.Time
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
// Notify all DPOs for overdue
s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline)
// Log to audit
s.pool.Exec(ctx, `
INSERT INTO consent_audit_log (action, entity_type, entity_id, details)
VALUES ('dsr_overdue', 'dsr', $1, $2)
`, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339)))
}
rows.Close()
return nil
}
// Helper functions
// --- Internal helpers ---
func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID, fromStatus *models.DSRStatus, toStatus models.DSRStatus, changedBy *uuid.UUID, comment string) {
s.pool.Exec(ctx, `
@@ -824,62 +455,6 @@ func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID
`, requestID, fromStatus, toStatus, changedBy, comment)
}
func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) {
if s.notificationService == nil {
return
}
// Notify all DPOs
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
defer rows.Close()
for rows.Next() {
var userID uuid.UUID
rows.Scan(&userID)
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived,
"Neue Betroffenenanfrage",
fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber),
map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber})
}
}
func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) {
if s.notificationService == nil {
return
}
dsr, _ := s.GetByID(ctx, dsrID)
if dsr != nil {
s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned,
"Betroffenenanfrage zugewiesen",
fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber),
map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber})
}
}
func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) {
if s.notificationService == nil {
return
}
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
fmt.Sprintf("Fristwarnung: %s", requestNumber),
fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")),
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft})
}
func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) {
if s.notificationService == nil {
return
}
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
defer rows.Close()
for rows.Next() {
var userID uuid.UUID
rows.Scan(&userID)
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
fmt.Sprintf("%s: %s", message, requestNumber),
fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")),
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline})
}
}
func isValidRequestType(rt models.DSRRequestType) bool {
switch rt {
case models.DSRTypeAccess, models.DSRTypeRectification, models.DSRTypeErasure,
@@ -891,12 +466,12 @@ func isValidRequestType(rt models.DSRRequestType) bool {
func isValidStatusTransition(from, to models.DSRStatus) bool {
validTransitions := map[models.DSRStatus][]models.DSRStatus{
models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled},
models.DSRStatusCompleted: {},
models.DSRStatusRejected: {},
models.DSRStatusCancelled: {},
models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled},
models.DSRStatusCompleted: {},
models.DSRStatusRejected: {},
models.DSRStatusCancelled: {},
}
allowed, exists := validTransitions[from]
@@ -910,38 +485,3 @@ func isValidStatusTransition(from, to models.DSRStatus) bool {
}
return false
}
func stringOrDefault(s *string, def string) string {
if s != nil {
return *s
}
return def
}
func replaceVariables(text string, variables map[string]string) string {
for k, v := range variables {
text = strings.ReplaceAll(text, "{{"+k+"}}", v)
}
return text
}
func stripHTML(html string) string {
// Simple HTML stripping - in production use a proper library
text := strings.ReplaceAll(html, "<br>", "\n")
text = strings.ReplaceAll(text, "<br/>", "\n")
text = strings.ReplaceAll(text, "<br />", "\n")
text = strings.ReplaceAll(text, "</p>", "\n\n")
// Remove all remaining tags
for {
start := strings.Index(text, "<")
if start == -1 {
break
}
end := strings.Index(text[start:], ">")
if end == -1 {
break
}
text = text[:start] + text[start+end+1:]
}
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,208 @@
package services
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// GetCommunications retrieves communications for a DSR
func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, direction, channel, communication_type, template_version_id,
subject, body_html, body_text, recipient_email, sent_at, error_message,
attachments, created_at, created_by
FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC
`, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query communications: %w", err)
}
defer rows.Close()
var comms []models.DSRCommunication
for rows.Next() {
var c models.DSRCommunication
var attachmentsJSON []byte
err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType,
&c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail,
&c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy)
if err != nil {
continue
}
json.Unmarshal(attachmentsJSON, &c.Attachments)
comms = append(comms, c)
}
return comms, nil
}
// SendCommunication sends a communication for a DSR
func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error {
// Get DSR details
dsr, err := s.GetByID(ctx, requestID)
if err != nil {
return err
}
// Get template if specified
var subject, bodyHTML, bodyText string
if req.TemplateVersionID != nil {
templateVersionID, _ := uuid.Parse(*req.TemplateVersionID)
err := s.pool.QueryRow(ctx, `
SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published'
`, templateVersionID).Scan(&subject, &bodyHTML, &bodyText)
if err != nil {
return fmt.Errorf("template version not found or not published: %w", err)
}
}
// Use custom content if provided
if req.CustomSubject != nil {
subject = *req.CustomSubject
}
if req.CustomBody != nil {
bodyHTML = *req.CustomBody
bodyText = stripHTML(*req.CustomBody)
}
// Replace variables
variables := map[string]string{
"requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"),
"request_number": dsr.RequestNumber,
"request_type_de": dsr.RequestType.Label(),
"request_date": dsr.CreatedAt.Format("02.01.2006"),
"deadline_date": dsr.DeadlineAt.Format("02.01.2006"),
}
for k, v := range req.Variables {
variables[k] = v
}
subject = replaceVariables(subject, variables)
bodyHTML = replaceVariables(bodyHTML, variables)
bodyText = replaceVariables(bodyText, variables)
// Send email
if s.emailService != nil {
err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText)
if err != nil {
// Log error but continue
_, _ = s.pool.Exec(ctx, `
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by)
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
dsr.RequesterEmail, err.Error(), sentBy)
return fmt.Errorf("failed to send email: %w", err)
}
}
// Log communication
now := time.Now()
_, err = s.pool.Exec(ctx, `
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by)
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
dsr.RequesterEmail, now, sentBy)
return err
}
// --- Notification helpers ---
func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) {
if s.notificationService == nil {
return
}
// Notify all DPOs
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
defer rows.Close()
for rows.Next() {
var userID uuid.UUID
rows.Scan(&userID)
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived,
"Neue Betroffenenanfrage",
fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber),
map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber})
}
}
func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) {
if s.notificationService == nil {
return
}
dsr, _ := s.GetByID(ctx, dsrID)
if dsr != nil {
s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned,
"Betroffenenanfrage zugewiesen",
fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber),
map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber})
}
}
func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) {
if s.notificationService == nil {
return
}
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
fmt.Sprintf("Fristwarnung: %s", requestNumber),
fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")),
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft})
}
func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) {
if s.notificationService == nil {
return
}
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
defer rows.Close()
for rows.Next() {
var userID uuid.UUID
rows.Scan(&userID)
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
fmt.Sprintf("%s: %s", message, requestNumber),
fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")),
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline})
}
}
// --- String utility helpers ---
func stringOrDefault(s *string, def string) string {
if s != nil {
return *s
}
return def
}
func replaceVariables(text string, variables map[string]string) string {
for k, v := range variables {
text = strings.ReplaceAll(text, "{{"+k+"}}", v)
}
return text
}
func stripHTML(html string) string {
// Simple HTML stripping - in production use a proper library
text := strings.ReplaceAll(html, "<br>", "\n")
text = strings.ReplaceAll(text, "<br/>", "\n")
text = strings.ReplaceAll(text, "<br />", "\n")
text = strings.ReplaceAll(text, "</p>", "\n\n")
// Remove all remaining tags
for {
start := strings.Index(text, "<")
if start == -1 {
break
}
end := strings.Index(text[start:], ">")
if end == -1 {
break
}
text = text[:start] + text[start+end+1:]
}
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,278 @@
package services
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// ExtendDeadline extends the deadline for a DSR
func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error {
// Default extension is 2 months (60 days) per Art. 12(3)
if days <= 0 {
days = 60
}
_, err := s.pool.Exec(ctx, `
UPDATE data_subject_requests
SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL,
extension_reason = $2,
updated_at = NOW()
WHERE id = $3
`, days, reason, id)
if err != nil {
return fmt.Errorf("failed to extend deadline: %w", err)
}
s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason))
return nil
}
// GetDashboardStats returns statistics for the admin dashboard
func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) {
stats := &models.DSRDashboardStats{
ByType: make(map[string]int),
ByStatus: make(map[string]int),
}
// Total requests
s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests)
// Pending requests (not completed, rejected, or cancelled)
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM data_subject_requests
WHERE status NOT IN ('completed', 'rejected', 'cancelled')
`).Scan(&stats.PendingRequests)
// Overdue requests
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) < NOW()
AND status NOT IN ('completed', 'rejected', 'cancelled')
`).Scan(&stats.OverdueRequests)
// Completed this month
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM data_subject_requests
WHERE status = 'completed'
AND completed_at >= DATE_TRUNC('month', NOW())
`).Scan(&stats.CompletedThisMonth)
// Average processing days
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0)
FROM data_subject_requests WHERE status = 'completed'
`).Scan(&stats.AverageProcessingDays)
// Count by type
rows, _ := s.pool.Query(ctx, `
SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type
`)
for rows.Next() {
var t string
var count int
rows.Scan(&t, &count)
stats.ByType[t] = count
}
rows.Close()
// Count by status
rows, _ = s.pool.Query(ctx, `
SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status
`)
for rows.Next() {
var s string
var count int
rows.Scan(&s, &count)
stats.ByStatus[s] = count
}
rows.Close()
// Upcoming deadlines (next 7 days)
rows, _ = s.pool.Query(ctx, `
SELECT id, request_number, request_type, status, requester_email, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days'
AND status NOT IN ('completed', 'rejected', 'cancelled')
ORDER BY deadline_at ASC LIMIT 10
`)
for rows.Next() {
var dsr models.DataSubjectRequest
rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt)
stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr)
}
rows.Close()
return stats, nil
}
// GetStatusHistory retrieves the status history for a DSR
func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at
FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC
`, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query status history: %w", err)
}
defer rows.Close()
var history []models.DSRStatusHistory
for rows.Next() {
var h models.DSRStatusHistory
var metadataJSON []byte
err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt)
if err != nil {
continue
}
json.Unmarshal(metadataJSON, &h.Metadata)
history = append(history, h)
}
return history, nil
}
// InitErasureExceptionChecks initializes exception checks for an erasure request
func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error {
exceptions := []struct {
Type string
Description string
}{
{models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"},
{models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"},
{models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"},
{models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"},
{models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"},
}
for _, exc := range exceptions {
_, err := s.pool.Exec(ctx, `
INSERT INTO dsr_exception_checks (request_id, exception_type, description)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
`, requestID, exc.Type, exc.Description)
if err != nil {
return fmt.Errorf("failed to create exception check: %w", err)
}
}
return nil
}
// GetExceptionChecks retrieves exception checks for a DSR
func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at
FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at
`, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query exception checks: %w", err)
}
defer rows.Close()
var checks []models.DSRExceptionCheck
for rows.Next() {
var c models.DSRExceptionCheck
err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies,
&c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt)
if err != nil {
continue
}
checks = append(checks, c)
}
return checks, nil
}
// UpdateExceptionCheck updates an exception check
func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsr_exception_checks
SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW()
WHERE id = $4
`, applies, notes, checkedBy, checkID)
return err
}
// ProcessDeadlines checks for approaching and overdue deadlines
func (s *DSRService) ProcessDeadlines(ctx context.Context) error {
now := time.Now()
// Find requests with deadlines in 3 days
threeDaysAhead := now.AddDate(0, 0, 3)
rows, _ := s.pool.Query(ctx, `
SELECT id, request_number, request_type, assigned_to, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
AND status NOT IN ('completed', 'rejected', 'cancelled')
`, now, threeDaysAhead)
for rows.Next() {
var id uuid.UUID
var requestNumber, requestType string
var assignedTo *uuid.UUID
var deadline time.Time
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
// Notify assigned user or all DPOs
if assignedTo != nil {
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3)
} else {
s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline)
}
}
rows.Close()
// Find requests with deadlines in 1 day
oneDayAhead := now.AddDate(0, 0, 1)
rows, _ = s.pool.Query(ctx, `
SELECT id, request_number, request_type, assigned_to, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
AND status NOT IN ('completed', 'rejected', 'cancelled')
`, now, oneDayAhead)
for rows.Next() {
var id uuid.UUID
var requestNumber, requestType string
var assignedTo *uuid.UUID
var deadline time.Time
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
if assignedTo != nil {
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1)
} else {
s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline)
}
}
rows.Close()
// Find overdue requests
rows, _ = s.pool.Query(ctx, `
SELECT id, request_number, request_type, assigned_to, deadline_at
FROM data_subject_requests
WHERE COALESCE(extended_deadline_at, deadline_at) < $1
AND status NOT IN ('completed', 'rejected', 'cancelled')
`, now)
for rows.Next() {
var id uuid.UUID
var requestNumber, requestType string
var assignedTo *uuid.UUID
var deadline time.Time
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
// Notify all DPOs for overdue
s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline)
// Log to audit
s.pool.Exec(ctx, `
INSERT INTO consent_audit_log (action, entity_type, entity_id, details)
VALUES ('dsr_overdue', 'dsr', $1, $2)
`, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339)))
}
rows.Close()
return nil
}

View File

@@ -3,7 +3,6 @@ package services
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"strings"
)
@@ -249,306 +248,3 @@ Ihr BreakPilot Team`, getDisplayName(name), appLink)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// renderTemplate renders an email HTML template
func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string {
templates := map[string]string{
"verification": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Willkommen bei BreakPilot!</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.</p>
<p style="text-align: center;">
<a href="{{.VerifyLink}}" class="button">E-Mail bestätigen</a>
</p>
<p>Dieser Link ist 24 Stunden gültig.</p>
<p>Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"password_reset": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Passwort zurücksetzen</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p style="text-align: center;">
<a href="{{.ResetLink}}" class="button">Passwort zurücksetzen</a>
</p>
<div class="warning">
<strong>Hinweis:</strong> Dieser Link ist nur 1 Stunde gültig.
</div>
<p>Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"new_version": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.info-box { background: #e0e7ff; border-left: 4px solid #6366f1; padding: 12px; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Neue Version: {{.DocumentName}}</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Wir haben unsere <strong>{{.DocumentName}}</strong> aktualisiert.</p>
<div class="info-box">
<strong>Wichtig:</strong> Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten <strong>{{.DeadlineDays}} Tage</strong>.
</div>
<p style="text-align: center;">
<a href="{{.ConsentLink}}" class="button">Dokument ansehen & bestätigen</a>
</p>
<p>Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"reminder": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #f59e0b; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>{{.Urgency}}: Ausstehende Bestätigungen</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.</p>
<div class="doc-list">
<strong>Ausstehende Dokumente:</strong>
<ul>
{{range .Documents}}<li>{{.}}</li>{{end}}
</ul>
</div>
<div class="warning">
<strong>Sie haben noch {{.DaysLeft}} Tage Zeit.</strong> Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt.
</div>
<p style="text-align: center;">
<a href="{{.ConsentLink}}" class="button">Jetzt bestätigen</a>
</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"suspended": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.alert { background: #fee2e2; border-left: 4px solid #ef4444; padding: 12px; margin: 20px 0; }
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Account vorübergehend gesperrt</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<div class="alert">
<strong>Ihr Account wurde vorübergehend gesperrt</strong>, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben.
</div>
<div class="doc-list">
<strong>Nicht bestätigte Dokumente:</strong>
<ul>
{{range .Documents}}<li>{{.}}</li>{{end}}
</ul>
</div>
<p>Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:</p>
<p style="text-align: center;">
<a href="{{.ConsentLink}}" class="button">Dokumente bestätigen & Account entsperren</a>
</p>
<p>Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"reactivated": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #22c55e, #16a34a); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #22c55e; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.success { background: #dcfce7; border-left: 4px solid #22c55e; padding: 12px; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Account wieder aktiviert!</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<div class="success">
<strong>Vielen Dank!</strong> Ihr Account wurde erfolgreich wieder aktiviert.
</div>
<p>Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.</p>
<p style="text-align: center;">
<a href="{{.AppLink}}" class="button">Zu BreakPilot</a>
</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"generic_notification": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>{{.Title}}</h1>
</div>
<div class="content">
<p>{{.Body}}</p>
<p style="text-align: center;">
<a href="{{.BaseURL}}/app" class="button">Zu BreakPilot</a>
</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
}
tmplStr, ok := templates[templateName]
if !ok {
return ""
}
tmpl, err := template.New(templateName).Parse(tmplStr)
if err != nil {
return ""
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return ""
}
return buf.String()
}
// SendConsentReminderEmail sends a simplified consent reminder email
func (s *EmailService) SendConsentReminderEmail(to, title, body string) error {
subject := title
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
"Title": title,
"Body": body,
"BaseURL": s.config.BaseURL,
})
return s.SendEmail(to, subject, htmlBody, body)
}
// SendGenericNotificationEmail sends a generic notification email
func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error {
subject := title
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
"Title": title,
"Body": body,
"BaseURL": s.config.BaseURL,
})
return s.SendEmail(to, subject, htmlBody, body)
}
// getDisplayName returns display name or fallback
func getDisplayName(name string) string {
if name != "" {
return name
}
return "Nutzer"
}

View File

@@ -0,0 +1,310 @@
package services
import (
"bytes"
"html/template"
)
// renderTemplate renders an email HTML template
func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string {
templates := map[string]string{
"verification": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Willkommen bei BreakPilot!</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.</p>
<p style="text-align: center;">
<a href="{{.VerifyLink}}" class="button">E-Mail bestätigen</a>
</p>
<p>Dieser Link ist 24 Stunden gültig.</p>
<p>Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"password_reset": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Passwort zurücksetzen</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
<p style="text-align: center;">
<a href="{{.ResetLink}}" class="button">Passwort zurücksetzen</a>
</p>
<div class="warning">
<strong>Hinweis:</strong> Dieser Link ist nur 1 Stunde gültig.
</div>
<p>Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"new_version": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.info-box { background: #e0e7ff; border-left: 4px solid #6366f1; padding: 12px; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Neue Version: {{.DocumentName}}</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Wir haben unsere <strong>{{.DocumentName}}</strong> aktualisiert.</p>
<div class="info-box">
<strong>Wichtig:</strong> Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten <strong>{{.DeadlineDays}} Tage</strong>.
</div>
<p style="text-align: center;">
<a href="{{.ConsentLink}}" class="button">Dokument ansehen & bestätigen</a>
</p>
<p>Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"reminder": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #f59e0b; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>{{.Urgency}}: Ausstehende Bestätigungen</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<p>Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.</p>
<div class="doc-list">
<strong>Ausstehende Dokumente:</strong>
<ul>
{{range .Documents}}<li>{{.}}</li>{{end}}
</ul>
</div>
<div class="warning">
<strong>Sie haben noch {{.DaysLeft}} Tage Zeit.</strong> Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt.
</div>
<p style="text-align: center;">
<a href="{{.ConsentLink}}" class="button">Jetzt bestätigen</a>
</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"suspended": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.alert { background: #fee2e2; border-left: 4px solid #ef4444; padding: 12px; margin: 20px 0; }
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Account vorübergehend gesperrt</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<div class="alert">
<strong>Ihr Account wurde vorübergehend gesperrt</strong>, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben.
</div>
<div class="doc-list">
<strong>Nicht bestätigte Dokumente:</strong>
<ul>
{{range .Documents}}<li>{{.}}</li>{{end}}
</ul>
</div>
<p>Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:</p>
<p style="text-align: center;">
<a href="{{.ConsentLink}}" class="button">Dokumente bestätigen & Account entsperren</a>
</p>
<p>Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"reactivated": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #22c55e, #16a34a); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #22c55e; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.success { background: #dcfce7; border-left: 4px solid #22c55e; padding: 12px; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>Account wieder aktiviert!</h1>
</div>
<div class="content">
<p>Hallo {{.Name}},</p>
<div class="success">
<strong>Vielen Dank!</strong> Ihr Account wurde erfolgreich wieder aktiviert.
</div>
<p>Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.</p>
<p style="text-align: center;">
<a href="{{.AppLink}}" class="button">Zu BreakPilot</a>
</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
"generic_notification": `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<h1>{{.Title}}</h1>
</div>
<div class="content">
<p>{{.Body}}</p>
<p style="text-align: center;">
<a href="{{.BaseURL}}/app" class="button">Zu BreakPilot</a>
</p>
</div>
<div class="footer">
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
</div>
</body>
</html>`,
}
tmplStr, ok := templates[templateName]
if !ok {
return ""
}
tmpl, err := template.New(templateName).Parse(tmplStr)
if err != nil {
return ""
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return ""
}
return buf.String()
}
// SendConsentReminderEmail sends a simplified consent reminder email
func (s *EmailService) SendConsentReminderEmail(to, title, body string) error {
subject := title
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
"Title": title,
"Body": body,
"BaseURL": s.config.BaseURL,
})
return s.SendEmail(to, subject, htmlBody, body)
}
// SendGenericNotificationEmail sends a generic notification email
func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error {
subject := title
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
"Title": title,
"Body": body,
"BaseURL": s.config.BaseURL,
})
return s.SendEmail(to, subject, htmlBody, body)
}
// getDisplayName returns display name or fallback
func getDisplayName(name string) string {
if name != "" {
return name
}
return "Nutzer"
}

View File

@@ -0,0 +1,174 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// SubmitForReview submits a version for review
func (s *EmailTemplateService) SubmitForReview(ctx context.Context, versionID, submitterID uuid.UUID, comment *string) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// Update status
_, err = tx.Exec(ctx, `
UPDATE email_template_versions SET status = 'review', updated_at = $1 WHERE id = $2
`, time.Now(), versionID)
if err != nil {
return fmt.Errorf("failed to update status: %w", err)
}
// Create approval record
_, err = tx.Exec(ctx, `
INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, uuid.New(), versionID, submitterID, "submitted_for_review", comment, time.Now())
if err != nil {
return fmt.Errorf("failed to create approval record: %w", err)
}
return tx.Commit(ctx)
}
// ApproveVersion approves a version (DSB)
func (s *EmailTemplateService) ApproveVersion(ctx context.Context, versionID, approverID uuid.UUID, comment *string, scheduledPublishAt *time.Time) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
now := time.Now()
status := "approved"
if scheduledPublishAt != nil {
status = "scheduled"
}
_, err = tx.Exec(ctx, `
UPDATE email_template_versions
SET status = $1, approved_by = $2, approved_at = $3, scheduled_publish_at = $4, updated_at = $5
WHERE id = $6
`, status, approverID, now, scheduledPublishAt, now, versionID)
if err != nil {
return fmt.Errorf("failed to approve version: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, uuid.New(), versionID, approverID, "approved", comment, now)
if err != nil {
return fmt.Errorf("failed to create approval record: %w", err)
}
return tx.Commit(ctx)
}
// PublishVersion publishes an approved version
func (s *EmailTemplateService) PublishVersion(ctx context.Context, versionID, publisherID uuid.UUID) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// Get version info to find template and language
var templateID uuid.UUID
var language string
err = tx.QueryRow(ctx, `SELECT template_id, language FROM email_template_versions WHERE id = $1`, versionID).Scan(&templateID, &language)
if err != nil {
return fmt.Errorf("failed to get version info: %w", err)
}
// Archive old published versions for same template and language
_, err = tx.Exec(ctx, `
UPDATE email_template_versions
SET status = 'archived', updated_at = $1
WHERE template_id = $2 AND language = $3 AND status = 'published'
`, time.Now(), templateID, language)
if err != nil {
return fmt.Errorf("failed to archive old versions: %w", err)
}
// Publish the new version
now := time.Now()
_, err = tx.Exec(ctx, `
UPDATE email_template_versions
SET status = 'published', published_at = $1, updated_at = $2
WHERE id = $3
`, now, now, versionID)
if err != nil {
return fmt.Errorf("failed to publish version: %w", err)
}
// Create approval record
_, err = tx.Exec(ctx, `
INSERT INTO email_template_approvals (id, version_id, approver_id, action, created_at)
VALUES ($1, $2, $3, $4, $5)
`, uuid.New(), versionID, publisherID, "published", now)
if err != nil {
return fmt.Errorf("failed to create approval record: %w", err)
}
return tx.Commit(ctx)
}
// RejectVersion rejects a version
func (s *EmailTemplateService) RejectVersion(ctx context.Context, versionID, rejectorID uuid.UUID, comment string) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
now := time.Now()
_, err = tx.Exec(ctx, `
UPDATE email_template_versions SET status = 'draft', updated_at = $1 WHERE id = $2
`, now, versionID)
if err != nil {
return fmt.Errorf("failed to reject version: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, uuid.New(), versionID, rejectorID, "rejected", &comment, now)
if err != nil {
return fmt.Errorf("failed to create approval record: %w", err)
}
return tx.Commit(ctx)
}
// GetApprovals returns approval history for a version
func (s *EmailTemplateService) GetApprovals(ctx context.Context, versionID uuid.UUID) ([]models.EmailTemplateApproval, error) {
rows, err := s.db.Query(ctx, `
SELECT id, version_id, approver_id, action, comment, created_at
FROM email_template_approvals
WHERE version_id = $1
ORDER BY created_at DESC
`, versionID)
if err != nil {
return nil, fmt.Errorf("failed to get approvals: %w", err)
}
defer rows.Close()
var approvals []models.EmailTemplateApproval
for rows.Next() {
var a models.EmailTemplateApproval
err := rows.Scan(&a.ID, &a.VersionID, &a.ApproverID, &a.Action, &a.Comment, &a.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan approval: %w", err)
}
approvals = append(approvals, a)
}
return approvals, nil
}

View File

@@ -0,0 +1,376 @@
package services
// Default German email templates for authentication and security events.
// Templates: Welcome, Email Verification, Password Reset, Password Changed,
// 2FA Enabled, 2FA Disabled, New Device Login, Suspicious Activity,
// Account Locked, Account Unlocked.
func (s *EmailTemplateService) getWelcomeTemplateDE() (string, string, string) {
subject := "Willkommen bei BreakPilot!"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">Willkommen bei BreakPilot!</h1>
<p>Hallo {{user_name}},</p>
<p>vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.</p>
<p>Sie können sich jetzt mit Ihrer E-Mail-Adresse <strong>{{user_email}}</strong> anmelden:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{login_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt anmelden</a>
</p>
<p>Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Willkommen bei BreakPilot!
Hallo {{user_name}},
vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.
Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden:
{{login_url}}
Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getEmailVerificationTemplateDE() (string, string, string) {
subject := "Bitte bestätigen Sie Ihre E-Mail-Adresse"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">E-Mail-Adresse bestätigen</h1>
<p>Hallo {{user_name}},</p>
<p>bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{verification_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">E-Mail bestätigen</a>
</p>
<p>Alternativ können Sie auch diesen Bestätigungscode eingeben: <strong>{{verification_code}}</strong></p>
<p><strong>Hinweis:</strong> Dieser Link ist nur {{expires_in}} gültig.</p>
<p>Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `E-Mail-Adresse bestätigen
Hallo {{user_name}},
bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:
{{verification_url}}
Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}}
Hinweis: Dieser Link ist nur {{expires_in}} gültig.
Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getPasswordResetTemplateDE() (string, string, string) {
subject := "Passwort zurücksetzen"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">Passwort zurücksetzen</h1>
<p>Hallo {{user_name}},</p>
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{reset_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Neues Passwort festlegen</a>
</p>
<p>Alternativ können Sie auch diesen Code verwenden: <strong>{{reset_code}}</strong></p>
<p><strong>Hinweis:</strong> Dieser Link ist nur {{expires_in}} gültig.</p>
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Sicherheitshinweis:</strong> Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Passwort zurücksetzen
Hallo {{user_name}},
Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:
{{reset_url}}
Alternativ können Sie auch diesen Code verwenden: {{reset_code}}
Hinweis: Dieser Link ist nur {{expires_in}} gültig.
Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getPasswordChangedTemplateDE() (string, string, string) {
subject := "Ihr Passwort wurde geändert"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">Passwort geändert</h1>
<p>Hallo {{user_name}},</p>
<p>Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.</p>
<p><strong>Details:</strong></p>
<ul>
<li>IP-Adresse: {{ip_address}}</li>
<li>Gerät: {{device_info}}</li>
</ul>
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Nicht Sie?</strong> Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Passwort geändert
Hallo {{user_name}},
Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.
Details:
- IP-Adresse: {{ip_address}}
- Gerät: {{device_info}}
Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) get2FAEnabledTemplateDE() (string, string, string) {
subject := "Zwei-Faktor-Authentifizierung aktiviert"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #059669;">2FA aktiviert</h1>
<p>Hallo {{user_name}},</p>
<p>Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.</p>
<p><strong>Gerät:</strong> {{device_info}}</p>
<p style="background-color: #d1fae5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Sicherheitstipp:</strong> Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren.
</p>
<p>Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `2FA aktiviert
Hallo {{user_name}},
Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.
Gerät: {{device_info}}
Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren.
Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) get2FADisabledTemplateDE() (string, string, string) {
subject := "Zwei-Faktor-Authentifizierung deaktiviert"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #dc2626;">2FA deaktiviert</h1>
<p>Hallo {{user_name}},</p>
<p>Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.</p>
<p><strong>IP-Adresse:</strong> {{ip_address}}</p>
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Warnung:</strong> Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren.
</p>
<p>Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `2FA deaktiviert
Hallo {{user_name}},
Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.
IP-Adresse: {{ip_address}}
Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren.
Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getNewDeviceLoginTemplateDE() (string, string, string) {
subject := "Neuer Login auf Ihrem Konto"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #f59e0b;">Neuer Login erkannt</h1>
<p>Hallo {{user_name}},</p>
<p>Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:</p>
<ul>
<li><strong>Zeitpunkt:</strong> {{login_time}}</li>
<li><strong>IP-Adresse:</strong> {{ip_address}}</li>
<li><strong>Gerät:</strong> {{device_info}}</li>
<li><strong>Standort:</strong> {{location}}</li>
</ul>
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Nicht Sie?</strong> Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Neuer Login erkannt
Hallo {{user_name}},
Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:
- Zeitpunkt: {{login_time}}
- IP-Adresse: {{ip_address}}
- Gerät: {{device_info}}
- Standort: {{location}}
Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getSuspiciousActivityTemplateDE() (string, string, string) {
subject := "Verdächtige Aktivität auf Ihrem Konto"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #dc2626;">Verdächtige Aktivität erkannt</h1>
<p>Hallo {{user_name}},</p>
<p>Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:</p>
<ul>
<li><strong>Art:</strong> {{activity_type}}</li>
<li><strong>Zeitpunkt:</strong> {{activity_time}}</li>
<li><strong>IP-Adresse:</strong> {{ip_address}}</li>
</ul>
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Wichtig:</strong> Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Verdächtige Aktivität erkannt
Hallo {{user_name}},
Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:
- Art: {{activity_type}}
- Zeitpunkt: {{activity_time}}
- IP-Adresse: {{ip_address}}
Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getAccountLockedTemplateDE() (string, string, string) {
subject := "Ihr Konto wurde gesperrt"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #dc2626;">Konto gesperrt</h1>
<p>Hallo {{user_name}},</p>
<p>Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:</p>
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
{{reason}}
</p>
<p>Ihr Konto wird automatisch entsperrt am: <strong>{{unlock_time}}</strong></p>
<p>Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Konto gesperrt
Hallo {{user_name}},
Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:
{{reason}}
Ihr Konto wird automatisch entsperrt am: {{unlock_time}}
Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getAccountUnlockedTemplateDE() (string, string, string) {
subject := "Ihr Konto wurde entsperrt"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #059669;">Konto entsperrt</h1>
<p>Hallo {{user_name}},</p>
<p>Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{login_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt anmelden</a>
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Konto entsperrt
Hallo {{user_name}},
Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.
Sie können sich jetzt wieder anmelden: {{login_url}}
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}

View File

@@ -0,0 +1,301 @@
package services
// Default German email templates for account lifecycle and consent events.
// Templates: Deletion Requested, Deletion Confirmed, Data Export Ready,
// Email Changed, New Version Published, Consent Reminder,
// Consent Deadline Warning, Account Suspended.
func (s *EmailTemplateService) getDeletionRequestedTemplateDE() (string, string, string) {
subject := "Bestätigung: Kontolöschung angefordert"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #dc2626;">Kontolöschung angefordert</h1>
<p>Hallo {{user_name}},</p>
<p>Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.</p>
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Wichtig:</strong> Ihr Konto und alle zugehörigen Daten werden endgültig am <strong>{{deletion_date}}</strong> gelöscht.
</p>
<p><strong>Folgende Daten werden gelöscht:</strong></p>
<p>{{data_info}}</p>
<p>Sie können die Löschung bis zum genannten Datum abbrechen:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{cancel_url}}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Löschung abbrechen</a>
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Kontolöschung angefordert
Hallo {{user_name}},
Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.
Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht.
Folgende Daten werden gelöscht:
{{data_info}}
Sie können die Löschung bis zum genannten Datum abbrechen: {{cancel_url}}
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getDeletionConfirmedTemplateDE() (string, string, string) {
subject := "Ihr Konto wurde gelöscht"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #6b7280;">Konto gelöscht</h1>
<p>Hallo {{user_name}},</p>
<p>Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.</p>
<p>Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{feedback_url}}" style="background-color: #6b7280; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Feedback geben</a>
</p>
<p>Vielen Dank für Ihre Zeit bei BreakPilot.</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Konto gelöscht
Hallo {{user_name}},
Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.
Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten: {{feedback_url}}
Vielen Dank für Ihre Zeit bei BreakPilot.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getDataExportReadyTemplateDE() (string, string, string) {
subject := "Ihr Datenexport ist bereit"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">Datenexport bereit</h1>
<p>Hallo {{user_name}},</p>
<p>Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit.</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{download_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Daten herunterladen ({{file_size}})</a>
</p>
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Hinweis:</strong> Der Download-Link ist nur {{expires_in}} gültig.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Datenexport bereit
Hallo {{user_name}},
Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit:
{{download_url}}
Dateigröße: {{file_size}}
Hinweis: Der Download-Link ist nur {{expires_in}} gültig.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getEmailChangedTemplateDE() (string, string, string) {
subject := "Ihre E-Mail-Adresse wurde geändert"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">E-Mail-Adresse geändert</h1>
<p>Hallo {{user_name}},</p>
<p>Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.</p>
<ul>
<li><strong>Alte Adresse:</strong> {{old_email}}</li>
<li><strong>Neue Adresse:</strong> {{new_email}}</li>
</ul>
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Nicht Sie?</strong> Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `E-Mail-Adresse geändert
Hallo {{user_name}},
Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.
- Alte Adresse: {{old_email}}
- Neue Adresse: {{new_email}}
Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getNewVersionPublishedTemplateDE() (string, string, string) {
subject := "Neue Version: {{document_name}} - Ihre Zustimmung ist erforderlich"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">Neue Dokumentversion</h1>
<p>Hallo {{user_name}},</p>
<p>Eine neue Version unserer <strong>{{document_name}}</strong> ({{document_type}}) wurde veröffentlicht.</p>
<p><strong>Version:</strong> {{version}}</p>
<p style="background-color: #dbeafe; padding: 15px; border-radius: 5px; margin: 20px 0;">
Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum <strong>{{deadline}}</strong>.
</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{consent_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt prüfen und zustimmen</a>
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Neue Dokumentversion
Hallo {{user_name}},
Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht.
Version: {{version}}
Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}.
Jetzt prüfen und zustimmen: {{consent_url}}
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getConsentReminderTemplateDE() (string, string, string) {
subject := "Erinnerung: Zustimmung zu {{document_name}} erforderlich"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #f59e0b;">Erinnerung: Zustimmung erforderlich</h1>
<p>Hallo {{user_name}},</p>
<p>Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur <strong>{{document_name}}</strong> noch aussteht.</p>
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Noch {{days_left}} Tage</strong> bis zur Frist am {{deadline}}.
</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{consent_url}}" style="background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt zustimmen</a>
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Erinnerung: Zustimmung erforderlich
Hallo {{user_name}},
Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht.
Noch {{days_left}} Tage bis zur Frist am {{deadline}}.
Jetzt zustimmen: {{consent_url}}
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getConsentDeadlineWarningTemplateDE() (string, string, string) {
subject := "DRINGEND: Zustimmung zu {{document_name}} läuft bald ab"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #dc2626;">Dringende Erinnerung</h1>
<p>Hallo {{user_name}},</p>
<p>Ihre Frist zur Zustimmung zur <strong>{{document_name}}</strong> läuft in <strong>{{hours_left}}</strong> ab!</p>
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Wichtig:</strong> {{consequences}}
</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{consent_url}}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Sofort zustimmen</a>
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Dringende Erinnerung
Hallo {{user_name}},
Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab!
Wichtig: {{consequences}}
Sofort zustimmen: {{consent_url}}
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}
func (s *EmailTemplateService) getAccountSuspendedTemplateDE() (string, string, string) {
subject := "Ihr Konto wurde suspendiert"
bodyHTML := `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #dc2626;">Konto suspendiert</h1>
<p>Hallo {{user_name}},</p>
<p>Ihr Konto wurde am {{suspended_at}} suspendiert.</p>
<p><strong>Grund:</strong> {{reason}}</p>
<p><strong>Fehlende Zustimmungen:</strong></p>
<p>{{documents}}</p>
<p>Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{consent_url}}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Konto reaktivieren</a>
</p>
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
</div>
</body>
</html>`
bodyText := `Konto suspendiert
Hallo {{user_name}},
Ihr Konto wurde am {{suspended_at}} suspendiert.
Grund: {{reason}}
Fehlende Zustimmungen:
{{documents}}
Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu: {{consent_url}}
Mit freundlichen Grüßen,
Ihr BreakPilot-Team`
return subject, bodyHTML, bodyText
}

View File

@@ -0,0 +1,273 @@
package services
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// RenderTemplate renders a template with variables
func (s *EmailTemplateService) RenderTemplate(version *models.EmailTemplateVersion, variables map[string]string) (*models.EmailPreviewResponse, error) {
subject := version.Subject
bodyHTML := version.BodyHTML
bodyText := version.BodyText
// Replace variables in format {{variable_name}}
re := regexp.MustCompile(`\{\{(\w+)\}\}`)
replaceFunc := func(content string) string {
return re.ReplaceAllStringFunc(content, func(match string) string {
varName := strings.Trim(match, "{}")
if val, ok := variables[varName]; ok {
return val
}
return match // Keep placeholder if variable not provided
})
}
return &models.EmailPreviewResponse{
Subject: replaceFunc(subject),
BodyHTML: replaceFunc(bodyHTML),
BodyText: replaceFunc(bodyText),
}, nil
}
// LogEmailSend logs a sent email
func (s *EmailTemplateService) LogEmailSend(ctx context.Context, log *models.EmailSendLog) error {
_, err := s.db.Exec(ctx, `
INSERT INTO email_send_logs
(id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, log.ID, log.UserID, log.VersionID, log.Recipient, log.Subject, log.Status,
log.ErrorMsg, log.Variables, log.SentAt, log.CreatedAt)
if err != nil {
return fmt.Errorf("failed to log email send: %w", err)
}
return nil
}
// GetEmailStats returns email statistics
func (s *EmailTemplateService) GetEmailStats(ctx context.Context) (*models.EmailStats, error) {
var stats models.EmailStats
err := s.db.QueryRow(ctx, `
SELECT
COUNT(*) as total_sent,
COUNT(*) FILTER (WHERE status = 'delivered') as delivered,
COUNT(*) FILTER (WHERE status = 'bounced') as bounced,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') as recent_sent
FROM email_send_logs
`).Scan(&stats.TotalSent, &stats.Delivered, &stats.Bounced, &stats.Failed, &stats.RecentSent)
if err != nil {
return nil, fmt.Errorf("failed to get email stats: %w", err)
}
if stats.TotalSent > 0 {
stats.DeliveryRate = float64(stats.Delivered) / float64(stats.TotalSent) * 100
}
return &stats, nil
}
// GetDefaultTemplateContent returns default content for a template type
func (s *EmailTemplateService) GetDefaultTemplateContent(templateType, language string) (subject, bodyHTML, bodyText string) {
// Default templates in German
if language == "de" {
switch templateType {
case models.EmailTypeWelcome:
return s.getWelcomeTemplateDE()
case models.EmailTypeEmailVerification:
return s.getEmailVerificationTemplateDE()
case models.EmailTypePasswordReset:
return s.getPasswordResetTemplateDE()
case models.EmailTypePasswordChanged:
return s.getPasswordChangedTemplateDE()
case models.EmailType2FAEnabled:
return s.get2FAEnabledTemplateDE()
case models.EmailType2FADisabled:
return s.get2FADisabledTemplateDE()
case models.EmailTypeNewDeviceLogin:
return s.getNewDeviceLoginTemplateDE()
case models.EmailTypeSuspiciousActivity:
return s.getSuspiciousActivityTemplateDE()
case models.EmailTypeAccountLocked:
return s.getAccountLockedTemplateDE()
case models.EmailTypeAccountUnlocked:
return s.getAccountUnlockedTemplateDE()
case models.EmailTypeDeletionRequested:
return s.getDeletionRequestedTemplateDE()
case models.EmailTypeDeletionConfirmed:
return s.getDeletionConfirmedTemplateDE()
case models.EmailTypeDataExportReady:
return s.getDataExportReadyTemplateDE()
case models.EmailTypeEmailChanged:
return s.getEmailChangedTemplateDE()
case models.EmailTypeNewVersionPublished:
return s.getNewVersionPublishedTemplateDE()
case models.EmailTypeConsentReminder:
return s.getConsentReminderTemplateDE()
case models.EmailTypeConsentDeadlineWarning:
return s.getConsentDeadlineWarningTemplateDE()
case models.EmailTypeAccountSuspended:
return s.getAccountSuspendedTemplateDE()
}
}
// Default English fallback
return "No template", "<p>No template available</p>", "No template available"
}
// InitDefaultTemplates creates default email templates if they don't exist
func (s *EmailTemplateService) InitDefaultTemplates(ctx context.Context) error {
templateTypes := []struct {
Type string
Name string
Description string
SortOrder int
}{
{models.EmailTypeWelcome, "Willkommens-E-Mail", "Wird nach erfolgreicher Registrierung gesendet", 1},
{models.EmailTypeEmailVerification, "E-Mail-Verifizierung", "Enthält Link zur E-Mail-Bestätigung", 2},
{models.EmailTypePasswordReset, "Passwort zurücksetzen", "Enthält Link zum Passwort-Reset", 3},
{models.EmailTypePasswordChanged, "Passwort geändert", "Bestätigung der Passwortänderung", 4},
{models.EmailType2FAEnabled, "2FA aktiviert", "Bestätigung der 2FA-Aktivierung", 5},
{models.EmailType2FADisabled, "2FA deaktiviert", "Warnung über 2FA-Deaktivierung", 6},
{models.EmailTypeNewDeviceLogin, "Neuer Login", "Benachrichtigung über Login von neuem Gerät", 7},
{models.EmailTypeSuspiciousActivity, "Verdächtige Aktivität", "Warnung über verdächtige Kontoaktivität", 8},
{models.EmailTypeAccountLocked, "Konto gesperrt", "Benachrichtigung über Kontosperrung", 9},
{models.EmailTypeAccountUnlocked, "Konto entsperrt", "Bestätigung der Kontoentsperrung", 10},
{models.EmailTypeDeletionRequested, "Löschung angefordert", "Bestätigung der Löschanfrage", 11},
{models.EmailTypeDeletionConfirmed, "Löschung bestätigt", "Bestätigung der Kontolöschung", 12},
{models.EmailTypeDataExportReady, "Datenexport bereit", "Benachrichtigung über fertigen Datenexport", 13},
{models.EmailTypeEmailChanged, "E-Mail geändert", "Bestätigung der E-Mail-Änderung", 14},
{models.EmailTypeNewVersionPublished, "Neue Version veröffentlicht", "Benachrichtigung über neue Dokumentversion", 15},
{models.EmailTypeConsentReminder, "Zustimmungs-Erinnerung", "Erinnerung an ausstehende Zustimmung", 16},
{models.EmailTypeConsentDeadlineWarning, "Frist-Warnung", "Dringende Warnung vor ablaufender Frist", 17},
{models.EmailTypeAccountSuspended, "Konto suspendiert", "Benachrichtigung über Kontosuspendierung", 18},
}
for _, tt := range templateTypes {
// Check if template exists
var exists bool
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM email_templates WHERE type = $1)`, tt.Type).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check template existence: %w", err)
}
if !exists {
desc := tt.Description
_, err = s.db.Exec(ctx, `
INSERT INTO email_templates (id, type, name, description, is_active, sort_order, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, uuid.New(), tt.Type, tt.Name, &desc, true, tt.SortOrder, time.Now(), time.Now())
if err != nil {
return fmt.Errorf("failed to create template %s: %w", tt.Type, err)
}
// Create default German version
template, err := s.GetTemplateByType(ctx, tt.Type)
if err != nil {
return fmt.Errorf("failed to get template %s: %w", tt.Type, err)
}
subject, bodyHTML, bodyText := s.GetDefaultTemplateContent(tt.Type, "de")
_, err = s.db.Exec(ctx, `
INSERT INTO email_template_versions
(id, template_id, version, language, subject, body_html, body_text, status, published_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, uuid.New(), template.ID, "1.0.0", "de", subject, bodyHTML, bodyText, "published", time.Now(), time.Now(), time.Now())
if err != nil {
return fmt.Errorf("failed to create template version %s: %w", tt.Type, err)
}
}
}
return nil
}
// GetSendLogs returns email send logs with optional filtering
func (s *EmailTemplateService) GetSendLogs(ctx context.Context, limit, offset int) ([]models.EmailSendLog, int, error) {
var total int
err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM email_send_logs`).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count send logs: %w", err)
}
rows, err := s.db.Query(ctx, `
SELECT id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, delivered_at, created_at
FROM email_send_logs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get send logs: %w", err)
}
defer rows.Close()
var logs []models.EmailSendLog
for rows.Next() {
var log models.EmailSendLog
err := rows.Scan(&log.ID, &log.UserID, &log.VersionID, &log.Recipient, &log.Subject,
&log.Status, &log.ErrorMsg, &log.Variables, &log.SentAt, &log.DeliveredAt, &log.CreatedAt)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan send log: %w", err)
}
logs = append(logs, log)
}
return logs, total, nil
}
// SendEmail sends an email using the specified template (stub - actual sending would use SMTP)
func (s *EmailTemplateService) SendEmail(ctx context.Context, templateType, language, recipient string, variables map[string]string, userID *uuid.UUID) error {
// Get published version
version, err := s.GetPublishedVersion(ctx, templateType, language)
if err != nil {
return fmt.Errorf("failed to get published version: %w", err)
}
// Render template
rendered, err := s.RenderTemplate(version, variables)
if err != nil {
return fmt.Errorf("failed to render template: %w", err)
}
// Log the send attempt
variablesJSON, _ := json.Marshal(variables)
now := time.Now()
sendLog := &models.EmailSendLog{
ID: uuid.New(),
UserID: userID,
VersionID: version.ID,
Recipient: recipient,
Subject: rendered.Subject,
Status: "queued",
Variables: ptr(string(variablesJSON)),
CreatedAt: now,
}
if err := s.LogEmailSend(ctx, sendLog); err != nil {
return fmt.Errorf("failed to log email send: %w", err)
}
// TODO: Actual email sending via SMTP would go here
// For now, we just log it as "sent"
_, err = s.db.Exec(ctx, `
UPDATE email_send_logs SET status = 'sent', sent_at = $1 WHERE id = $2
`, now, sendLog.ID)
if err != nil {
return fmt.Errorf("failed to update send log status: %w", err)
}
return nil
}
func ptr(s string) *string {
return &s
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// GetAllTemplateTypes returns all available email template types with their variables
func (s *EmailTemplateService) GetAllTemplateTypes() []models.EmailTemplateVariables {
return []models.EmailTemplateVariables{
{
TemplateType: models.EmailTypeWelcome,
Variables: []string{"user_name", "user_email", "login_url", "support_email"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"user_email": "E-Mail-Adresse des Benutzers",
"login_url": "URL zur Login-Seite",
"support_email": "Support E-Mail-Adresse",
},
},
{
TemplateType: models.EmailTypeEmailVerification,
Variables: []string{"user_name", "verification_url", "verification_code", "expires_in"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"verification_url": "URL zur E-Mail-Verifizierung",
"verification_code": "Verifizierungscode",
"expires_in": "Gültigkeit des Links (z.B. '24 Stunden')",
},
},
{
TemplateType: models.EmailTypePasswordReset,
Variables: []string{"user_name", "reset_url", "reset_code", "expires_in", "ip_address"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"reset_url": "URL zum Passwort-Reset",
"reset_code": "Reset-Code",
"expires_in": "Gültigkeit des Links",
"ip_address": "IP-Adresse der Anfrage",
},
},
{
TemplateType: models.EmailTypePasswordChanged,
Variables: []string{"user_name", "changed_at", "ip_address", "device_info", "support_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"changed_at": "Zeitpunkt der Änderung",
"ip_address": "IP-Adresse",
"device_info": "Geräte-Informationen",
"support_url": "URL zum Support",
},
},
{
TemplateType: models.EmailType2FAEnabled,
Variables: []string{"user_name", "enabled_at", "device_info", "security_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"enabled_at": "Zeitpunkt der Aktivierung",
"device_info": "Geräte-Informationen",
"security_url": "URL zu Sicherheitseinstellungen",
},
},
{
TemplateType: models.EmailType2FADisabled,
Variables: []string{"user_name", "disabled_at", "ip_address", "security_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"disabled_at": "Zeitpunkt der Deaktivierung",
"ip_address": "IP-Adresse",
"security_url": "URL zu Sicherheitseinstellungen",
},
},
{
TemplateType: models.EmailTypeNewDeviceLogin,
Variables: []string{"user_name", "login_time", "ip_address", "device_info", "location", "security_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"login_time": "Zeitpunkt des Logins",
"ip_address": "IP-Adresse",
"device_info": "Geräte-Informationen",
"location": "Ungefährer Standort",
"security_url": "URL zu Sicherheitseinstellungen",
},
},
{
TemplateType: models.EmailTypeSuspiciousActivity,
Variables: []string{"user_name", "activity_type", "activity_time", "ip_address", "security_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"activity_type": "Art der Aktivität",
"activity_time": "Zeitpunkt",
"ip_address": "IP-Adresse",
"security_url": "URL zu Sicherheitseinstellungen",
},
},
{
TemplateType: models.EmailTypeAccountLocked,
Variables: []string{"user_name", "locked_at", "reason", "unlock_time", "support_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"locked_at": "Zeitpunkt der Sperrung",
"reason": "Grund der Sperrung",
"unlock_time": "Zeitpunkt der automatischen Entsperrung",
"support_url": "URL zum Support",
},
},
{
TemplateType: models.EmailTypeAccountUnlocked,
Variables: []string{"user_name", "unlocked_at", "login_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"unlocked_at": "Zeitpunkt der Entsperrung",
"login_url": "URL zur Login-Seite",
},
},
{
TemplateType: models.EmailTypeDeletionRequested,
Variables: []string{"user_name", "requested_at", "deletion_date", "cancel_url", "data_info"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"requested_at": "Zeitpunkt der Anfrage",
"deletion_date": "Datum der endgültigen Löschung",
"cancel_url": "URL zum Abbrechen",
"data_info": "Info über zu löschende Daten",
},
},
{
TemplateType: models.EmailTypeDeletionConfirmed,
Variables: []string{"user_name", "deleted_at", "feedback_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"deleted_at": "Zeitpunkt der Löschung",
"feedback_url": "URL für Feedback",
},
},
{
TemplateType: models.EmailTypeDataExportReady,
Variables: []string{"user_name", "download_url", "expires_in", "file_size"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"download_url": "URL zum Download",
"expires_in": "Gültigkeit des Download-Links",
"file_size": "Dateigröße",
},
},
{
TemplateType: models.EmailTypeEmailChanged,
Variables: []string{"user_name", "old_email", "new_email", "changed_at", "support_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"old_email": "Alte E-Mail-Adresse",
"new_email": "Neue E-Mail-Adresse",
"changed_at": "Zeitpunkt der Änderung",
"support_url": "URL zum Support",
},
},
{
TemplateType: models.EmailTypeEmailChangeVerify,
Variables: []string{"user_name", "new_email", "verification_url", "expires_in"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"new_email": "Neue E-Mail-Adresse",
"verification_url": "URL zur Verifizierung",
"expires_in": "Gültigkeit des Links",
},
},
{
TemplateType: models.EmailTypeNewVersionPublished,
Variables: []string{"user_name", "document_name", "document_type", "version", "consent_url", "deadline"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"document_name": "Name des Dokuments",
"document_type": "Typ des Dokuments",
"version": "Versionsnummer",
"consent_url": "URL zur Zustimmung",
"deadline": "Frist für die Zustimmung",
},
},
{
TemplateType: models.EmailTypeConsentReminder,
Variables: []string{"user_name", "document_name", "days_left", "consent_url", "deadline"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"document_name": "Name des Dokuments",
"days_left": "Verbleibende Tage",
"consent_url": "URL zur Zustimmung",
"deadline": "Frist für die Zustimmung",
},
},
{
TemplateType: models.EmailTypeConsentDeadlineWarning,
Variables: []string{"user_name", "document_name", "hours_left", "consent_url", "consequences"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"document_name": "Name des Dokuments",
"hours_left": "Verbleibende Stunden",
"consent_url": "URL zur Zustimmung",
"consequences": "Konsequenzen bei Nicht-Zustimmung",
},
},
{
TemplateType: models.EmailTypeAccountSuspended,
Variables: []string{"user_name", "suspended_at", "reason", "documents", "consent_url"},
Descriptions: map[string]string{
"user_name": "Name des Benutzers",
"suspended_at": "Zeitpunkt der Suspendierung",
"reason": "Grund der Suspendierung",
"documents": "Liste der fehlenden Zustimmungen",
"consent_url": "URL zur Zustimmung",
},
},
}
}
// GetSettings returns global email settings
func (s *EmailTemplateService) GetSettings(ctx context.Context) (*models.EmailTemplateSettings, error) {
var settings models.EmailTemplateSettings
err := s.db.QueryRow(ctx, `
SELECT id, logo_url, logo_base64, company_name, sender_name, sender_email,
reply_to_email, footer_html, footer_text, primary_color, secondary_color,
updated_at, updated_by
FROM email_template_settings
LIMIT 1
`).Scan(&settings.ID, &settings.LogoURL, &settings.LogoBase64, &settings.CompanyName,
&settings.SenderName, &settings.SenderEmail, &settings.ReplyToEmail, &settings.FooterHTML,
&settings.FooterText, &settings.PrimaryColor, &settings.SecondaryColor,
&settings.UpdatedAt, &settings.UpdatedBy)
if err != nil {
return nil, fmt.Errorf("failed to get email settings: %w", err)
}
return &settings, nil
}
// UpdateSettings updates global email settings
func (s *EmailTemplateService) UpdateSettings(ctx context.Context, req *models.UpdateEmailTemplateSettingsRequest, updatedBy uuid.UUID) error {
query := "UPDATE email_template_settings SET updated_at = $1, updated_by = $2"
args := []interface{}{time.Now(), updatedBy}
argIdx := 3
if req.LogoURL != nil {
query += fmt.Sprintf(", logo_url = $%d", argIdx)
args = append(args, *req.LogoURL)
argIdx++
}
if req.LogoBase64 != nil {
query += fmt.Sprintf(", logo_base64 = $%d", argIdx)
args = append(args, *req.LogoBase64)
argIdx++
}
if req.CompanyName != nil {
query += fmt.Sprintf(", company_name = $%d", argIdx)
args = append(args, *req.CompanyName)
argIdx++
}
if req.SenderName != nil {
query += fmt.Sprintf(", sender_name = $%d", argIdx)
args = append(args, *req.SenderName)
argIdx++
}
if req.SenderEmail != nil {
query += fmt.Sprintf(", sender_email = $%d", argIdx)
args = append(args, *req.SenderEmail)
argIdx++
}
if req.ReplyToEmail != nil {
query += fmt.Sprintf(", reply_to_email = $%d", argIdx)
args = append(args, *req.ReplyToEmail)
argIdx++
}
if req.FooterHTML != nil {
query += fmt.Sprintf(", footer_html = $%d", argIdx)
args = append(args, *req.FooterHTML)
argIdx++
}
if req.FooterText != nil {
query += fmt.Sprintf(", footer_text = $%d", argIdx)
args = append(args, *req.FooterText)
argIdx++
}
if req.PrimaryColor != nil {
query += fmt.Sprintf(", primary_color = $%d", argIdx)
args = append(args, *req.PrimaryColor)
argIdx++
}
if req.SecondaryColor != nil {
query += fmt.Sprintf(", secondary_color = $%d", argIdx)
args = append(args, *req.SecondaryColor)
argIdx++
}
_, err := s.db.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to update settings: %w", err)
}
return nil
}

View File

@@ -334,210 +334,3 @@ func (s *GradeService) GetClassGradesBySubject(ctx context.Context, classID, sub
return overviews, nil
}
// ========================================
// Grade Statistics
// ========================================
// GetStudentGradeAverage calculates the overall grade average for a student
func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) {
query := `
SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0)
FROM grades
WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true`
var average float64
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average)
if err != nil {
return 0, fmt.Errorf("failed to calculate average: %w", err)
}
return average, nil
}
// GetSubjectGradeStatistics gets grade statistics for a subject in a class
func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) {
query := `
SELECT
COUNT(DISTINCT g.student_id) as student_count,
AVG(g.value) as class_average,
MIN(g.value) as best_grade,
MAX(g.value) as worst_grade,
COUNT(*) as total_grades
FROM grades g
JOIN students s ON g.student_id = s.id
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true`
var studentCount, totalGrades int
var classAverage, bestGrade, worstGrade float64
err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan(
&studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades,
)
if err != nil {
return nil, fmt.Errorf("failed to get statistics: %w", err)
}
// Grade distribution (for German grades 1-6)
distributionQuery := `
SELECT
COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1,
COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2,
COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3,
COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4,
COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5,
COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6
FROM grades g
JOIN students s ON g.student_id = s.id
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')`
var g1, g2, g3, g4, g5, g6 int
err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan(
&g1, &g2, &g3, &g4, &g5, &g6,
)
if err != nil {
// Non-fatal, continue without distribution
g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0
}
return map[string]interface{}{
"student_count": studentCount,
"class_average": classAverage,
"best_grade": bestGrade,
"worst_grade": worstGrade,
"total_grades": totalGrades,
"distribution": map[string]int{
"1": g1,
"2": g2,
"3": g3,
"4": g4,
"5": g5,
"6": g6,
},
}, nil
}
// ========================================
// Grade Comments
// ========================================
// AddGradeComment adds a comment to a grade
func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) {
gradeComment := &models.GradeComment{
ID: uuid.New(),
GradeID: gradeID,
TeacherID: teacherID,
Comment: comment,
IsPrivate: isPrivate,
CreatedAt: time.Now(),
}
query := `
INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID,
gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt,
).Scan(&gradeComment.ID)
if err != nil {
return nil, fmt.Errorf("failed to add grade comment: %w", err)
}
return gradeComment, nil
}
// GetGradeComments gets comments for a grade
func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) {
query := `
SELECT id, grade_id, teacher_id, comment, is_private, created_at
FROM grade_comments
WHERE grade_id = $1`
if !includePrivate {
query += ` AND is_private = false`
}
query += ` ORDER BY created_at DESC`
rows, err := s.db.Pool.Query(ctx, query, gradeID)
if err != nil {
return nil, fmt.Errorf("failed to get grade comments: %w", err)
}
defer rows.Close()
var comments []models.GradeComment
for rows.Next() {
var comment models.GradeComment
err := rows.Scan(
&comment.ID, &comment.GradeID, &comment.TeacherID,
&comment.Comment, &comment.IsPrivate, &comment.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan grade comment: %w", err)
}
comments = append(comments, comment)
}
return comments, nil
}
// ========================================
// Parent Notifications
// ========================================
func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) {
if s.matrix == nil {
return
}
// Get student info and Matrix room
var studentFirstName, studentLastName, matrixDMRoom string
err := s.db.Pool.QueryRow(ctx, `
SELECT first_name, last_name, matrix_dm_room
FROM students
WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
if err != nil || matrixDMRoom == "" {
return
}
// Get subject name
var subjectName string
err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName)
if err != nil {
return
}
studentName := studentFirstName + " " + studentLastName
gradeType := s.getGradeTypeDisplayName(grade.Type)
// Send Matrix notification
err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value)
if err != nil {
fmt.Printf("Failed to send grade notification: %v\n", err)
}
}
func (s *GradeService) getGradeTypeDisplayName(gradeType string) string {
switch gradeType {
case models.GradeTypeExam:
return "Klassenarbeit"
case models.GradeTypeTest:
return "Test"
case models.GradeTypeOral:
return "Mündliche Note"
case models.GradeTypeHomework:
return "Hausaufgabe"
case models.GradeTypeProject:
return "Projekt"
case models.GradeTypeParticipation:
return "Mitarbeit"
case models.GradeTypeSemester:
return "Halbjahreszeugnis"
case models.GradeTypeFinal:
return "Zeugnisnote"
default:
return gradeType
}
}

View File

@@ -0,0 +1,219 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// ========================================
// Grade Statistics
// ========================================
// GetStudentGradeAverage calculates the overall grade average for a student
func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) {
query := `
SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0)
FROM grades
WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true`
var average float64
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average)
if err != nil {
return 0, fmt.Errorf("failed to calculate average: %w", err)
}
return average, nil
}
// GetSubjectGradeStatistics gets grade statistics for a subject in a class
func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) {
query := `
SELECT
COUNT(DISTINCT g.student_id) as student_count,
AVG(g.value) as class_average,
MIN(g.value) as best_grade,
MAX(g.value) as worst_grade,
COUNT(*) as total_grades
FROM grades g
JOIN students s ON g.student_id = s.id
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true`
var studentCount, totalGrades int
var classAverage, bestGrade, worstGrade float64
err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan(
&studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades,
)
if err != nil {
return nil, fmt.Errorf("failed to get statistics: %w", err)
}
// Grade distribution (for German grades 1-6)
distributionQuery := `
SELECT
COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1,
COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2,
COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3,
COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4,
COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5,
COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6
FROM grades g
JOIN students s ON g.student_id = s.id
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')`
var g1, g2, g3, g4, g5, g6 int
err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan(
&g1, &g2, &g3, &g4, &g5, &g6,
)
if err != nil {
// Non-fatal, continue without distribution
g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0
}
return map[string]interface{}{
"student_count": studentCount,
"class_average": classAverage,
"best_grade": bestGrade,
"worst_grade": worstGrade,
"total_grades": totalGrades,
"distribution": map[string]int{
"1": g1,
"2": g2,
"3": g3,
"4": g4,
"5": g5,
"6": g6,
},
}, nil
}
// ========================================
// Grade Comments
// ========================================
// AddGradeComment adds a comment to a grade
func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) {
gradeComment := &models.GradeComment{
ID: uuid.New(),
GradeID: gradeID,
TeacherID: teacherID,
Comment: comment,
IsPrivate: isPrivate,
CreatedAt: time.Now(),
}
query := `
INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID,
gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt,
).Scan(&gradeComment.ID)
if err != nil {
return nil, fmt.Errorf("failed to add grade comment: %w", err)
}
return gradeComment, nil
}
// GetGradeComments gets comments for a grade
func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) {
query := `
SELECT id, grade_id, teacher_id, comment, is_private, created_at
FROM grade_comments
WHERE grade_id = $1`
if !includePrivate {
query += ` AND is_private = false`
}
query += ` ORDER BY created_at DESC`
rows, err := s.db.Pool.Query(ctx, query, gradeID)
if err != nil {
return nil, fmt.Errorf("failed to get grade comments: %w", err)
}
defer rows.Close()
var comments []models.GradeComment
for rows.Next() {
var comment models.GradeComment
err := rows.Scan(
&comment.ID, &comment.GradeID, &comment.TeacherID,
&comment.Comment, &comment.IsPrivate, &comment.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan grade comment: %w", err)
}
comments = append(comments, comment)
}
return comments, nil
}
// ========================================
// Parent Notifications
// ========================================
func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) {
if s.matrix == nil {
return
}
// Get student info and Matrix room
var studentFirstName, studentLastName, matrixDMRoom string
err := s.db.Pool.QueryRow(ctx, `
SELECT first_name, last_name, matrix_dm_room
FROM students
WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
if err != nil || matrixDMRoom == "" {
return
}
// Get subject name
var subjectName string
err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName)
if err != nil {
return
}
studentName := studentFirstName + " " + studentLastName
gradeType := s.getGradeTypeDisplayName(grade.Type)
// Send Matrix notification
err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value)
if err != nil {
fmt.Printf("Failed to send grade notification: %v\n", err)
}
}
func (s *GradeService) getGradeTypeDisplayName(gradeType string) string {
switch gradeType {
case models.GradeTypeExam:
return "Klassenarbeit"
case models.GradeTypeTest:
return "Test"
case models.GradeTypeOral:
return "Mündliche Note"
case models.GradeTypeHomework:
return "Hausaufgabe"
case models.GradeTypeProject:
return "Projekt"
case models.GradeTypeParticipation:
return "Mitarbeit"
case models.GradeTypeSemester:
return "Halbjahreszeugnis"
case models.GradeTypeFinal:
return "Zeugnisnote"
default:
return gradeType
}
}

View File

@@ -2,17 +2,10 @@ package jitsi
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// JitsiService handles Jitsi Meet integration for video conferences
@@ -292,275 +285,3 @@ func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string,
return s.CreateMeetingLink(ctx, meeting)
}
// ========================================
// JWT Generation
// ========================================
// generateJWT creates a signed JWT for Jitsi authentication
func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) {
if s.appSecret == "" {
return "", nil, fmt.Errorf("app secret not configured")
}
now := time.Now()
// Default expiration: 24 hours or based on meeting duration
expiration := now.Add(24 * time.Hour)
if meeting.Duration > 0 {
expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute)
}
if meeting.StartTime != nil {
expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute)
}
claims := JWTClaims{
Audience: "jitsi",
Issuer: s.appID,
Subject: "meet.jitsi",
Room: roomName,
ExpiresAt: expiration.Unix(),
NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period
Moderator: meeting.Moderator,
Context: &JWTContext{
User: &JWTUser{
ID: uuid.New().String(),
Name: meeting.DisplayName,
Email: meeting.Email,
Avatar: meeting.Avatar,
Moderator: meeting.Moderator,
},
},
}
// Add features if specified
if meeting.Features != nil {
claims.Features = &JWTFeatures{
Recording: boolToString(meeting.Features.Recording),
Livestreaming: boolToString(meeting.Features.Livestreaming),
Transcription: boolToString(meeting.Features.Transcription),
OutboundCall: boolToString(meeting.Features.OutboundCall),
}
}
// Create JWT
token, err := s.signJWT(claims)
if err != nil {
return "", nil, err
}
return token, &expiration, nil
}
// signJWT creates and signs a JWT token
func (s *JitsiService) signJWT(claims JWTClaims) (string, error) {
// Header
header := map[string]string{
"alg": "HS256",
"typ": "JWT",
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
// Payload
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
// Encode
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Sign
message := headerB64 + "." + payloadB64
h := hmac.New(sha256.New, []byte(s.appSecret))
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return message + "." + signature, nil
}
// ========================================
// Health Check
// ========================================
// HealthCheck verifies the Jitsi server is accessible
func (s *JitsiService) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("jitsi server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("jitsi server error: status %d", resp.StatusCode)
}
return nil
}
// GetServerInfo returns information about the Jitsi server
func (s *JitsiService) GetServerInfo() map[string]string {
return map[string]string{
"base_url": s.baseURL,
"app_id": s.appID,
"auth_enabled": boolToString(s.appSecret != ""),
}
}
// ========================================
// URL Building
// ========================================
// BuildEmbedURL creates an embeddable iframe URL
func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string {
params := url.Values{}
if displayName != "" {
params.Set("userInfo.displayName", displayName)
}
if config != nil {
if config.StartWithAudioMuted {
params.Set("config.startWithAudioMuted", "true")
}
if config.StartWithVideoMuted {
params.Set("config.startWithVideoMuted", "true")
}
if config.DisableDeepLinking {
params.Set("config.disableDeepLinking", "true")
}
}
embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName))
if len(params) > 0 {
embedURL += "#" + params.Encode()
}
return embedURL
}
// BuildIFrameCode generates HTML iframe code for embedding
func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string {
if width == 0 {
width = 800
}
if height == 0 {
height = 600
}
return fmt.Sprintf(
`<iframe src="%s/%s" width="%d" height="%d" allow="camera; microphone; fullscreen; display-capture; autoplay" style="border: 0;"></iframe>`,
s.baseURL,
s.sanitizeRoomName(roomName),
width,
height,
)
}
// ========================================
// Helper Functions
// ========================================
// generateRoomName creates a unique room name
func (s *JitsiService) generateRoomName() string {
return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8])
}
// generateTrainingRoomName creates a room name for training sessions
func (s *JitsiService) generateTrainingRoomName(title string) string {
sanitized := s.sanitizeRoomName(title)
if sanitized == "" {
sanitized = "schulung"
}
return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504"))
}
// sanitizeRoomName removes invalid characters from room names
func (s *JitsiService) sanitizeRoomName(name string) string {
// Replace spaces and special characters
result := strings.ToLower(name)
result = strings.ReplaceAll(result, " ", "-")
result = strings.ReplaceAll(result, "ä", "ae")
result = strings.ReplaceAll(result, "ö", "oe")
result = strings.ReplaceAll(result, "ü", "ue")
result = strings.ReplaceAll(result, "ß", "ss")
// Remove any remaining non-alphanumeric characters except hyphen
var cleaned strings.Builder
for _, r := range result {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
cleaned.WriteRune(r)
}
}
// Remove consecutive hyphens
result = cleaned.String()
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
// Trim hyphens from start and end
result = strings.Trim(result, "-")
// Limit length
if len(result) > 50 {
result = result[:50]
}
return result
}
// generatePassword creates a random meeting password
func (s *JitsiService) generatePassword() string {
return uuid.New().String()[:8]
}
// buildConfigParams creates URL parameters from config
func (s *JitsiService) buildConfigParams(config *MeetingConfig) string {
params := url.Values{}
if config.StartWithAudioMuted {
params.Set("config.startWithAudioMuted", "true")
}
if config.StartWithVideoMuted {
params.Set("config.startWithVideoMuted", "true")
}
if config.DisableDeepLinking {
params.Set("config.disableDeepLinking", "true")
}
if config.RequireDisplayName {
params.Set("config.requireDisplayName", "true")
}
if config.EnableLobby {
params.Set("config.enableLobby", "true")
}
return params.Encode()
}
// boolToString converts bool to "true"/"false" string
func boolToString(b bool) string {
if b {
return "true"
}
return "false"
}
// GetBaseURL returns the configured base URL
func (s *JitsiService) GetBaseURL() string {
return s.baseURL
}
// IsAuthEnabled returns whether JWT authentication is configured
func (s *JitsiService) IsAuthEnabled() bool {
return s.appSecret != ""
}

View File

@@ -0,0 +1,290 @@
package jitsi
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"crypto/hmac"
"crypto/sha256"
"github.com/google/uuid"
)
// ========================================
// JWT Generation
// ========================================
// generateJWT creates a signed JWT for Jitsi authentication
func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) {
if s.appSecret == "" {
return "", nil, fmt.Errorf("app secret not configured")
}
now := time.Now()
// Default expiration: 24 hours or based on meeting duration
expiration := now.Add(24 * time.Hour)
if meeting.Duration > 0 {
expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute)
}
if meeting.StartTime != nil {
expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute)
}
claims := JWTClaims{
Audience: "jitsi",
Issuer: s.appID,
Subject: "meet.jitsi",
Room: roomName,
ExpiresAt: expiration.Unix(),
NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period
Moderator: meeting.Moderator,
Context: &JWTContext{
User: &JWTUser{
ID: uuid.New().String(),
Name: meeting.DisplayName,
Email: meeting.Email,
Avatar: meeting.Avatar,
Moderator: meeting.Moderator,
},
},
}
// Add features if specified
if meeting.Features != nil {
claims.Features = &JWTFeatures{
Recording: boolToString(meeting.Features.Recording),
Livestreaming: boolToString(meeting.Features.Livestreaming),
Transcription: boolToString(meeting.Features.Transcription),
OutboundCall: boolToString(meeting.Features.OutboundCall),
}
}
// Create JWT
token, err := s.signJWT(claims)
if err != nil {
return "", nil, err
}
return token, &expiration, nil
}
// signJWT creates and signs a JWT token
func (s *JitsiService) signJWT(claims JWTClaims) (string, error) {
// Header
header := map[string]string{
"alg": "HS256",
"typ": "JWT",
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
// Payload
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
// Encode
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Sign
message := headerB64 + "." + payloadB64
h := hmac.New(sha256.New, []byte(s.appSecret))
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return message + "." + signature, nil
}
// ========================================
// Health Check
// ========================================
// HealthCheck verifies the Jitsi server is accessible
func (s *JitsiService) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("jitsi server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("jitsi server error: status %d", resp.StatusCode)
}
return nil
}
// GetServerInfo returns information about the Jitsi server
func (s *JitsiService) GetServerInfo() map[string]string {
return map[string]string{
"base_url": s.baseURL,
"app_id": s.appID,
"auth_enabled": boolToString(s.appSecret != ""),
}
}
// ========================================
// URL Building
// ========================================
// BuildEmbedURL creates an embeddable iframe URL
func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string {
params := url.Values{}
if displayName != "" {
params.Set("userInfo.displayName", displayName)
}
if config != nil {
if config.StartWithAudioMuted {
params.Set("config.startWithAudioMuted", "true")
}
if config.StartWithVideoMuted {
params.Set("config.startWithVideoMuted", "true")
}
if config.DisableDeepLinking {
params.Set("config.disableDeepLinking", "true")
}
}
embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName))
if len(params) > 0 {
embedURL += "#" + params.Encode()
}
return embedURL
}
// BuildIFrameCode generates HTML iframe code for embedding
func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string {
if width == 0 {
width = 800
}
if height == 0 {
height = 600
}
return fmt.Sprintf(
`<iframe src="%s/%s" width="%d" height="%d" allow="camera; microphone; fullscreen; display-capture; autoplay" style="border: 0;"></iframe>`,
s.baseURL,
s.sanitizeRoomName(roomName),
width,
height,
)
}
// ========================================
// Helper Functions
// ========================================
// generateRoomName creates a unique room name
func (s *JitsiService) generateRoomName() string {
return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8])
}
// generateTrainingRoomName creates a room name for training sessions
func (s *JitsiService) generateTrainingRoomName(title string) string {
sanitized := s.sanitizeRoomName(title)
if sanitized == "" {
sanitized = "schulung"
}
return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504"))
}
// sanitizeRoomName removes invalid characters from room names
func (s *JitsiService) sanitizeRoomName(name string) string {
// Replace spaces and special characters
result := strings.ToLower(name)
result = strings.ReplaceAll(result, " ", "-")
result = strings.ReplaceAll(result, "ä", "ae")
result = strings.ReplaceAll(result, "ö", "oe")
result = strings.ReplaceAll(result, "ü", "ue")
result = strings.ReplaceAll(result, "ß", "ss")
// Remove any remaining non-alphanumeric characters except hyphen
var cleaned strings.Builder
for _, r := range result {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
cleaned.WriteRune(r)
}
}
// Remove consecutive hyphens
result = cleaned.String()
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
// Trim hyphens from start and end
result = strings.Trim(result, "-")
// Limit length
if len(result) > 50 {
result = result[:50]
}
return result
}
// generatePassword creates a random meeting password
func (s *JitsiService) generatePassword() string {
return uuid.New().String()[:8]
}
// buildConfigParams creates URL parameters from config
func (s *JitsiService) buildConfigParams(config *MeetingConfig) string {
params := url.Values{}
if config.StartWithAudioMuted {
params.Set("config.startWithAudioMuted", "true")
}
if config.StartWithVideoMuted {
params.Set("config.startWithVideoMuted", "true")
}
if config.DisableDeepLinking {
params.Set("config.disableDeepLinking", "true")
}
if config.RequireDisplayName {
params.Set("config.requireDisplayName", "true")
}
if config.EnableLobby {
params.Set("config.enableLobby", "true")
}
return params.Encode()
}
// boolToString converts bool to "true"/"false" string
func boolToString(b bool) string {
if b {
return "true"
}
return "false"
}
// GetBaseURL returns the configured base URL
func (s *JitsiService) GetBaseURL() string {
return s.baseURL
}
// IsAuthEnabled returns whether JWT authentication is configured
func (s *JitsiService) IsAuthEnabled() bool {
return s.appSecret != ""
}

View File

@@ -1,11 +1,9 @@
package matrix
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
@@ -392,157 +390,3 @@ func (s *MatrixService) SetUserPowerLevel(ctx context.Context, roomID string, us
return nil
}
// ========================================
// Messaging
// ========================================
// SendMessage sends a text message to a room
func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error {
req := SendMessageRequest{
MsgType: "m.text",
Body: message,
}
return s.sendEvent(ctx, roomID, "m.room.message", req)
}
// SendHTMLMessage sends an HTML-formatted message to a room
func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error {
req := SendMessageRequest{
MsgType: "m.text",
Body: plainText,
Format: "org.matrix.custom.html",
FormattedBody: htmlBody,
}
return s.sendEvent(ctx, roomID, "m.room.message", req)
}
// SendAbsenceNotification sends an absence notification to parents
func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error {
plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber)
htmlBody := fmt.Sprintf(`<h3>⚠️ Abwesenheitsmeldung</h3>
<p>Ihr Kind <strong>%s</strong> war heute (%s) in der <strong>%d. Stunde</strong> nicht im Unterricht anwesend.</p>
<p>Bitte bestätigen Sie den Grund der Abwesenheit.</p>
<ul>
<li>✅ Entschuldigt (Krankheit)</li>
<li>📋 Arztbesuch</li>
<li>❓ Sonstiges (bitte erläutern)</li>
</ul>`, studentName, date, lessonNumber)
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
}
// SendGradeNotification sends a grade notification to parents
func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error {
plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade)
htmlBody := fmt.Sprintf(`<h3>📊 Neue Note eingetragen</h3>
<p>Für <strong>%s</strong> wurde eine neue Note eingetragen:</p>
<table>
<tr><td>Fach:</td><td><strong>%s</strong></td></tr>
<tr><td>Art:</td><td>%s</td></tr>
<tr><td>Note:</td><td><strong>%.1f</strong></td></tr>
</table>`, studentName, subject, gradeType, grade)
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
}
// SendClassAnnouncement sends an announcement to a class info room
func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error {
plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName)
htmlBody := fmt.Sprintf(`<h3>📢 %s</h3>
<p>%s</p>
<p><em>— %s</em></p>`, title, content, teacherName)
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
}
// ========================================
// Internal Helpers
// ========================================
func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error {
body, err := json.Marshal(content)
if err != nil {
return fmt.Errorf("failed to marshal content: %w", err)
}
txnID := uuid.New().String()
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
url.PathEscape(roomID), url.PathEscape(eventType), txnID)
resp, err := s.doRequest(ctx, "PUT", endpoint, body)
if err != nil {
return fmt.Errorf("failed to send event: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return s.parseError(resp)
}
return nil
}
func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) {
fullURL := s.homeserverURL + endpoint
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.accessToken)
req.Header.Set("Content-Type", "application/json")
return s.httpClient.Do(req)
}
func (s *MatrixService) parseError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var errResp struct {
ErrCode string `json:"errcode"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &errResp); err != nil {
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
}
return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error)
}
// ========================================
// Health Check
// ========================================
// HealthCheck checks if the Matrix server is reachable
func (s *MatrixService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil)
if err != nil {
return fmt.Errorf("matrix server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("matrix server returned status %d", resp.StatusCode)
}
return nil
}
// GetServerName returns the configured server name
func (s *MatrixService) GetServerName() string {
return s.serverName
}
// GenerateUserID generates a Matrix user ID from a username
func (s *MatrixService) GenerateUserID(username string) string {
return fmt.Sprintf("@%s:%s", username, s.serverName)
}

View File

@@ -0,0 +1,168 @@
package matrix
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/google/uuid"
)
// ========================================
// Messaging
// ========================================
// SendMessage sends a text message to a room
func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error {
req := SendMessageRequest{
MsgType: "m.text",
Body: message,
}
return s.sendEvent(ctx, roomID, "m.room.message", req)
}
// SendHTMLMessage sends an HTML-formatted message to a room
func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error {
req := SendMessageRequest{
MsgType: "m.text",
Body: plainText,
Format: "org.matrix.custom.html",
FormattedBody: htmlBody,
}
return s.sendEvent(ctx, roomID, "m.room.message", req)
}
// SendAbsenceNotification sends an absence notification to parents
func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error {
plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber)
htmlBody := fmt.Sprintf(`<h3>⚠️ Abwesenheitsmeldung</h3>
<p>Ihr Kind <strong>%s</strong> war heute (%s) in der <strong>%d. Stunde</strong> nicht im Unterricht anwesend.</p>
<p>Bitte bestätigen Sie den Grund der Abwesenheit.</p>
<ul>
<li>✅ Entschuldigt (Krankheit)</li>
<li>📋 Arztbesuch</li>
<li>❓ Sonstiges (bitte erläutern)</li>
</ul>`, studentName, date, lessonNumber)
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
}
// SendGradeNotification sends a grade notification to parents
func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error {
plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade)
htmlBody := fmt.Sprintf(`<h3>📊 Neue Note eingetragen</h3>
<p>Für <strong>%s</strong> wurde eine neue Note eingetragen:</p>
<table>
<tr><td>Fach:</td><td><strong>%s</strong></td></tr>
<tr><td>Art:</td><td>%s</td></tr>
<tr><td>Note:</td><td><strong>%.1f</strong></td></tr>
</table>`, studentName, subject, gradeType, grade)
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
}
// SendClassAnnouncement sends an announcement to a class info room
func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error {
plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName)
htmlBody := fmt.Sprintf(`<h3>📢 %s</h3>
<p>%s</p>
<p><em>— %s</em></p>`, title, content, teacherName)
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
}
// ========================================
// Internal Helpers
// ========================================
func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error {
body, err := json.Marshal(content)
if err != nil {
return fmt.Errorf("failed to marshal content: %w", err)
}
txnID := uuid.New().String()
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
url.PathEscape(roomID), url.PathEscape(eventType), txnID)
resp, err := s.doRequest(ctx, "PUT", endpoint, body)
if err != nil {
return fmt.Errorf("failed to send event: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return s.parseError(resp)
}
return nil
}
func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) {
fullURL := s.homeserverURL + endpoint
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.accessToken)
req.Header.Set("Content-Type", "application/json")
return s.httpClient.Do(req)
}
func (s *MatrixService) parseError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var errResp struct {
ErrCode string `json:"errcode"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &errResp); err != nil {
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
}
return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error)
}
// ========================================
// Health Check
// ========================================
// HealthCheck checks if the Matrix server is reachable
func (s *MatrixService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil)
if err != nil {
return fmt.Errorf("matrix server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("matrix server returned status %d", resp.StatusCode)
}
return nil
}
// GetServerName returns the configured server name
func (s *MatrixService) GetServerName() string {
return s.serverName
}
// GenerateUserID generates a Matrix user ID from a username
func (s *MatrixService) GenerateUserID(username string) string {
return fmt.Sprintf("@%s:%s", username, s.serverName)
}

View File

@@ -12,7 +12,6 @@ import (
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
@@ -20,25 +19,25 @@ import (
)
var (
ErrInvalidClient = errors.New("invalid_client")
ErrInvalidGrant = errors.New("invalid_grant")
ErrInvalidScope = errors.New("invalid_scope")
ErrInvalidRequest = errors.New("invalid_request")
ErrUnauthorizedClient = errors.New("unauthorized_client")
ErrAccessDenied = errors.New("access_denied")
ErrInvalidRedirectURI = errors.New("invalid redirect_uri")
ErrCodeExpired = errors.New("authorization code expired")
ErrCodeUsed = errors.New("authorization code already used")
ErrPKCERequired = errors.New("PKCE code_challenge required for public clients")
ErrPKCEVerifyFailed = errors.New("PKCE verification failed")
ErrInvalidClient = errors.New("invalid_client")
ErrInvalidGrant = errors.New("invalid_grant")
ErrInvalidScope = errors.New("invalid_scope")
ErrInvalidRequest = errors.New("invalid_request")
ErrUnauthorizedClient = errors.New("unauthorized_client")
ErrAccessDenied = errors.New("access_denied")
ErrInvalidRedirectURI = errors.New("invalid redirect_uri")
ErrCodeExpired = errors.New("authorization code expired")
ErrCodeUsed = errors.New("authorization code already used")
ErrPKCERequired = errors.New("PKCE code_challenge required for public clients")
ErrPKCEVerifyFailed = errors.New("PKCE verification failed")
)
// OAuthService handles OAuth 2.0 Authorization Code Flow with PKCE
type OAuthService struct {
db *pgxpool.Pool
jwtSecret string
authCodeExpiration time.Duration
accessTokenExpiration time.Duration
db *pgxpool.Pool
jwtSecret string
authCodeExpiration time.Duration
accessTokenExpiration time.Duration
refreshTokenExpiration time.Duration
}
@@ -47,12 +46,16 @@ func NewOAuthService(db *pgxpool.Pool, jwtSecret string) *OAuthService {
return &OAuthService{
db: db,
jwtSecret: jwtSecret,
authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly
accessTokenExpiration: time.Hour, // 1 hour
refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days
authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly
accessTokenExpiration: time.Hour, // 1 hour
refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days
}
}
// ========================================
// Client Validation
// ========================================
// ValidateClient validates an OAuth client
func (s *OAuthService) ValidateClient(ctx context.Context, clientID string) (*models.OAuthClient, error) {
var client models.OAuthClient
@@ -133,6 +136,10 @@ func (s *OAuthService) ValidateScopes(client *models.OAuthClient, requestedScope
return validScopes, nil
}
// ========================================
// Authorization Code
// ========================================
// GenerateAuthorizationCode generates a new authorization code
func (s *OAuthService) GenerateAuthorizationCode(
ctx context.Context,
@@ -181,295 +188,9 @@ func (s *OAuthService) GenerateAuthorizationCode(
return code, nil
}
// ExchangeAuthorizationCode exchanges an authorization code for tokens
func (s *OAuthService) ExchangeAuthorizationCode(
ctx context.Context,
code string,
clientID string,
redirectURI string,
codeVerifier string,
) (*models.OAuthTokenResponse, error) {
// Hash the code to look it up
codeHash := sha256.Sum256([]byte(code))
hashedCode := hex.EncodeToString(codeHash[:])
var authCode models.OAuthAuthorizationCode
var scopesJSON []byte
err := s.db.QueryRow(ctx, `
SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at
FROM oauth_authorization_codes WHERE code = $1
`, hashedCode).Scan(
&authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI,
&scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod,
&authCode.ExpiresAt, &authCode.UsedAt,
)
if err != nil {
return nil, ErrInvalidGrant
}
// Check if code was already used
if authCode.UsedAt != nil {
return nil, ErrCodeUsed
}
// Check if code is expired
if time.Now().After(authCode.ExpiresAt) {
return nil, ErrCodeExpired
}
// Verify client_id matches
if authCode.ClientID != clientID {
return nil, ErrInvalidGrant
}
// Verify redirect_uri matches
if authCode.RedirectURI != redirectURI {
return nil, ErrInvalidGrant
}
// Verify PKCE if code_challenge was provided
if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" {
if codeVerifier == "" {
return nil, ErrPKCEVerifyFailed
}
var expectedChallenge string
if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" {
// SHA256 hash of verifier
hash := sha256.Sum256([]byte(codeVerifier))
expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:])
} else {
// Plain method
expectedChallenge = codeVerifier
}
if expectedChallenge != *authCode.CodeChallenge {
return nil, ErrPKCEVerifyFailed
}
}
// Mark code as used
_, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark code as used: %w", err)
}
// Parse scopes
var scopes []string
json.Unmarshal(scopesJSON, &scopes)
// Generate tokens
return s.generateTokens(ctx, clientID, authCode.UserID, scopes)
}
// RefreshAccessToken refreshes an access token using a refresh token
func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) {
// Hash the refresh token
tokenHash := sha256.Sum256([]byte(refreshToken))
hashedToken := hex.EncodeToString(tokenHash[:])
var rt models.OAuthRefreshToken
var scopesJSON []byte
err := s.db.QueryRow(ctx, `
SELECT id, client_id, user_id, scopes, expires_at, revoked_at
FROM oauth_refresh_tokens WHERE token_hash = $1
`, hashedToken).Scan(
&rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt,
)
if err != nil {
return nil, ErrInvalidGrant
}
// Check if token is revoked
if rt.RevokedAt != nil {
return nil, ErrInvalidGrant
}
// Check if token is expired
if time.Now().After(rt.ExpiresAt) {
return nil, ErrInvalidGrant
}
// Verify client_id matches
if rt.ClientID != clientID {
return nil, ErrInvalidGrant
}
// Parse original scopes
var originalScopes []string
json.Unmarshal(scopesJSON, &originalScopes)
// Determine scopes for new tokens
var scopes []string
if requestedScope != "" {
// Validate that requested scopes are subset of original scopes
originalMap := make(map[string]bool)
for _, s := range originalScopes {
originalMap[s] = true
}
for _, s := range strings.Split(requestedScope, " ") {
if originalMap[s] {
scopes = append(scopes, s)
}
}
if len(scopes) == 0 {
return nil, ErrInvalidScope
}
} else {
scopes = originalScopes
}
// Revoke old refresh token (rotate)
_, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID)
// Generate new tokens
return s.generateTokens(ctx, clientID, rt.UserID, scopes)
}
// generateTokens generates access and refresh tokens
func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) {
// Get user info for JWT
var user models.User
err := s.db.QueryRow(ctx, `
SELECT id, email, name, role, account_status FROM users WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus)
if err != nil {
return nil, ErrInvalidGrant
}
// Generate access token (JWT)
accessTokenClaims := jwt.MapClaims{
"sub": userID.String(),
"email": user.Email,
"role": user.Role,
"account_status": user.AccountStatus,
"client_id": clientID,
"scope": strings.Join(scopes, " "),
"iat": time.Now().Unix(),
"exp": time.Now().Add(s.accessTokenExpiration).Unix(),
"iss": "breakpilot-consent-service",
"aud": clientID,
}
if user.Name != nil {
accessTokenClaims["name"] = *user.Name
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims)
accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret))
if err != nil {
return nil, fmt.Errorf("failed to sign access token: %w", err)
}
// Hash access token for storage
accessTokenHash := sha256.Sum256([]byte(accessTokenString))
hashedAccessToken := hex.EncodeToString(accessTokenHash[:])
scopesJSON, _ := json.Marshal(scopes)
// Store access token
var accessTokenID uuid.UUID
err = s.db.QueryRow(ctx, `
INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID)
if err != nil {
return nil, fmt.Errorf("failed to store access token: %w", err)
}
// Generate refresh token (opaque)
refreshTokenBytes := make([]byte, 32)
if _, err := rand.Read(refreshTokenBytes); err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes)
// Hash refresh token for storage
refreshTokenHash := sha256.Sum256([]byte(refreshTokenString))
hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:])
// Store refresh token
_, err = s.db.Exec(ctx, `
INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration))
if err != nil {
return nil, fmt.Errorf("failed to store refresh token: %w", err)
}
return &models.OAuthTokenResponse{
AccessToken: accessTokenString,
TokenType: "Bearer",
ExpiresIn: int(s.accessTokenExpiration.Seconds()),
RefreshToken: refreshTokenString,
Scope: strings.Join(scopes, " "),
}, nil
}
// RevokeToken revokes an access or refresh token
func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error {
tokenHash := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(tokenHash[:])
// Try to revoke as access token
if tokenTypeHint == "" || tokenTypeHint == "access_token" {
result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
if err == nil && result.RowsAffected() > 0 {
return nil
}
}
// Try to revoke as refresh token
if tokenTypeHint == "" || tokenTypeHint == "refresh_token" {
result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
if err == nil && result.RowsAffected() > 0 {
return nil
}
}
return nil // RFC 7009: Always return success
}
// ValidateAccessToken validates an OAuth access token
func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.jwtSecret), nil
})
if err != nil {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
// Check if token is revoked in database
tokenHash := sha256.Sum256([]byte(tokenString))
hashedToken := hex.EncodeToString(tokenHash[:])
var revokedAt *time.Time
err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt)
if err == nil && revokedAt != nil {
return nil, ErrInvalidToken
}
return &claims, nil
}
// ========================================
// Client Management (Admin)
// ========================================
// GetClientByID retrieves an OAuth client by its client_id
func (s *OAuthService) GetClientByID(ctx context.Context, clientID string) (*models.OAuthClient, error) {

View File

@@ -0,0 +1,308 @@
package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/breakpilot/consent-service/internal/models"
)
// ExchangeAuthorizationCode exchanges an authorization code for tokens
func (s *OAuthService) ExchangeAuthorizationCode(
ctx context.Context,
code string,
clientID string,
redirectURI string,
codeVerifier string,
) (*models.OAuthTokenResponse, error) {
// Hash the code to look it up
codeHash := sha256.Sum256([]byte(code))
hashedCode := hex.EncodeToString(codeHash[:])
var authCode models.OAuthAuthorizationCode
var scopesJSON []byte
err := s.db.QueryRow(ctx, `
SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at
FROM oauth_authorization_codes WHERE code = $1
`, hashedCode).Scan(
&authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI,
&scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod,
&authCode.ExpiresAt, &authCode.UsedAt,
)
if err != nil {
return nil, ErrInvalidGrant
}
// Check if code was already used
if authCode.UsedAt != nil {
return nil, ErrCodeUsed
}
// Check if code is expired
if time.Now().After(authCode.ExpiresAt) {
return nil, ErrCodeExpired
}
// Verify client_id matches
if authCode.ClientID != clientID {
return nil, ErrInvalidGrant
}
// Verify redirect_uri matches
if authCode.RedirectURI != redirectURI {
return nil, ErrInvalidGrant
}
// Verify PKCE if code_challenge was provided
if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" {
if codeVerifier == "" {
return nil, ErrPKCEVerifyFailed
}
var expectedChallenge string
if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" {
// SHA256 hash of verifier
hash := sha256.Sum256([]byte(codeVerifier))
expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:])
} else {
// Plain method
expectedChallenge = codeVerifier
}
if expectedChallenge != *authCode.CodeChallenge {
return nil, ErrPKCEVerifyFailed
}
}
// Mark code as used
_, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark code as used: %w", err)
}
// Parse scopes
var scopes []string
json.Unmarshal(scopesJSON, &scopes)
// Generate tokens
return s.generateTokens(ctx, clientID, authCode.UserID, scopes)
}
// RefreshAccessToken refreshes an access token using a refresh token
func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) {
// Hash the refresh token
tokenHash := sha256.Sum256([]byte(refreshToken))
hashedToken := hex.EncodeToString(tokenHash[:])
var rt models.OAuthRefreshToken
var scopesJSON []byte
err := s.db.QueryRow(ctx, `
SELECT id, client_id, user_id, scopes, expires_at, revoked_at
FROM oauth_refresh_tokens WHERE token_hash = $1
`, hashedToken).Scan(
&rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt,
)
if err != nil {
return nil, ErrInvalidGrant
}
// Check if token is revoked
if rt.RevokedAt != nil {
return nil, ErrInvalidGrant
}
// Check if token is expired
if time.Now().After(rt.ExpiresAt) {
return nil, ErrInvalidGrant
}
// Verify client_id matches
if rt.ClientID != clientID {
return nil, ErrInvalidGrant
}
// Parse original scopes
var originalScopes []string
json.Unmarshal(scopesJSON, &originalScopes)
// Determine scopes for new tokens
var scopes []string
if requestedScope != "" {
// Validate that requested scopes are subset of original scopes
originalMap := make(map[string]bool)
for _, s := range originalScopes {
originalMap[s] = true
}
for _, s := range strings.Split(requestedScope, " ") {
if originalMap[s] {
scopes = append(scopes, s)
}
}
if len(scopes) == 0 {
return nil, ErrInvalidScope
}
} else {
scopes = originalScopes
}
// Revoke old refresh token (rotate)
_, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID)
// Generate new tokens
return s.generateTokens(ctx, clientID, rt.UserID, scopes)
}
// generateTokens generates access and refresh tokens
func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) {
// Get user info for JWT
var user models.User
err := s.db.QueryRow(ctx, `
SELECT id, email, name, role, account_status FROM users WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus)
if err != nil {
return nil, ErrInvalidGrant
}
// Generate access token (JWT)
accessTokenClaims := jwt.MapClaims{
"sub": userID.String(),
"email": user.Email,
"role": user.Role,
"account_status": user.AccountStatus,
"client_id": clientID,
"scope": strings.Join(scopes, " "),
"iat": time.Now().Unix(),
"exp": time.Now().Add(s.accessTokenExpiration).Unix(),
"iss": "breakpilot-consent-service",
"aud": clientID,
}
if user.Name != nil {
accessTokenClaims["name"] = *user.Name
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims)
accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret))
if err != nil {
return nil, fmt.Errorf("failed to sign access token: %w", err)
}
// Hash access token for storage
accessTokenHash := sha256.Sum256([]byte(accessTokenString))
hashedAccessToken := hex.EncodeToString(accessTokenHash[:])
scopesJSON, _ := json.Marshal(scopes)
// Store access token
var accessTokenID uuid.UUID
err = s.db.QueryRow(ctx, `
INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID)
if err != nil {
return nil, fmt.Errorf("failed to store access token: %w", err)
}
// Generate refresh token (opaque)
refreshTokenBytes := make([]byte, 32)
if _, err := rand.Read(refreshTokenBytes); err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes)
// Hash refresh token for storage
refreshTokenHash := sha256.Sum256([]byte(refreshTokenString))
hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:])
// Store refresh token
_, err = s.db.Exec(ctx, `
INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration))
if err != nil {
return nil, fmt.Errorf("failed to store refresh token: %w", err)
}
return &models.OAuthTokenResponse{
AccessToken: accessTokenString,
TokenType: "Bearer",
ExpiresIn: int(s.accessTokenExpiration.Seconds()),
RefreshToken: refreshTokenString,
Scope: strings.Join(scopes, " "),
}, nil
}
// RevokeToken revokes an access or refresh token
func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error {
tokenHash := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(tokenHash[:])
// Try to revoke as access token
if tokenTypeHint == "" || tokenTypeHint == "access_token" {
result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
if err == nil && result.RowsAffected() > 0 {
return nil
}
}
// Try to revoke as refresh token
if tokenTypeHint == "" || tokenTypeHint == "refresh_token" {
result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
if err == nil && result.RowsAffected() > 0 {
return nil
}
}
return nil // RFC 7009: Always return success
}
// ValidateAccessToken validates an OAuth access token
func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.jwtSecret), nil
})
if err != nil {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
// Check if token is revoked in database
tokenHash := sha256.Sum256([]byte(tokenString))
hashedToken := hex.EncodeToString(tokenHash[:])
var revokedAt *time.Time
err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt)
if err == nil && revokedAt != nil {
return nil, ErrInvalidToken
}
return &claims, nil
}

View File

@@ -2,8 +2,6 @@ package services
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
@@ -35,21 +33,21 @@ func NewSchoolService(db *database.DB, matrixService *matrix.MatrixService) *Sch
// CreateSchool creates a new school
func (s *SchoolService) CreateSchool(ctx context.Context, req models.CreateSchoolRequest) (*models.School, error) {
school := &models.School{
ID: uuid.New(),
Name: req.Name,
ShortName: req.ShortName,
Type: req.Type,
Address: req.Address,
City: req.City,
ID: uuid.New(),
Name: req.Name,
ShortName: req.ShortName,
Type: req.Type,
Address: req.Address,
City: req.City,
PostalCode: req.PostalCode,
State: req.State,
Country: "DE",
Phone: req.Phone,
Email: req.Email,
Website: req.Website,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
State: req.State,
Country: "DE",
Phone: req.Phone,
Email: req.Email,
Website: req.Website,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
@@ -298,350 +296,6 @@ func (s *SchoolService) ListClasses(ctx context.Context, schoolID, schoolYearID
return classes, nil
}
// ========================================
// Student Management
// ========================================
// CreateStudent creates a new student
func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) {
classID, err := uuid.Parse(req.ClassID)
if err != nil {
return nil, fmt.Errorf("invalid class ID: %w", err)
}
student := &models.Student{
ID: uuid.New(),
SchoolID: schoolID,
ClassID: classID,
StudentNumber: req.StudentNumber,
FirstName: req.FirstName,
LastName: req.LastName,
Gender: req.Gender,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if req.DateOfBirth != nil {
dob, err := time.Parse("2006-01-02", *req.DateOfBirth)
if err == nil {
student.DateOfBirth = &dob
}
}
query := `
INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id`
err = s.db.Pool.QueryRow(ctx, query,
student.ID, student.SchoolID, student.ClassID, student.StudentNumber,
student.FirstName, student.LastName, student.DateOfBirth, student.Gender,
student.IsActive, student.CreatedAt, student.UpdatedAt,
).Scan(&student.ID)
if err != nil {
return nil, fmt.Errorf("failed to create student: %w", err)
}
return student, nil
}
// GetStudent retrieves a student by ID
func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) {
query := `
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
FROM students
WHERE id = $1`
student := &models.Student{}
err := s.db.Pool.QueryRow(ctx, query, studentID).Scan(
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
&student.StudentNumber, &student.FirstName, &student.LastName,
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get student: %w", err)
}
return student, nil
}
// ListStudentsByClass lists all students in a class
func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) {
query := `
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
FROM students
WHERE class_id = $1 AND is_active = true
ORDER BY last_name, first_name`
rows, err := s.db.Pool.Query(ctx, query, classID)
if err != nil {
return nil, fmt.Errorf("failed to list students: %w", err)
}
defer rows.Close()
var students []models.Student
for rows.Next() {
var student models.Student
err := rows.Scan(
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
&student.StudentNumber, &student.FirstName, &student.LastName,
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan student: %w", err)
}
students = append(students, student)
}
return students, nil
}
// ========================================
// Teacher Management
// ========================================
// CreateTeacher creates a new teacher linked to a user account
func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) {
teacher := &models.Teacher{
ID: uuid.New(),
SchoolID: schoolID,
UserID: userID,
TeacherCode: teacherCode,
Title: title,
FirstName: firstName,
LastName: lastName,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode,
teacher.Title, teacher.FirstName, teacher.LastName,
teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt,
).Scan(&teacher.ID)
if err != nil {
return nil, fmt.Errorf("failed to create teacher: %w", err)
}
return teacher, nil
}
// GetTeacher retrieves a teacher by ID
func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) {
query := `
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
FROM teachers
WHERE id = $1`
teacher := &models.Teacher{}
err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan(
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get teacher: %w", err)
}
return teacher, nil
}
// GetTeacherByUserID retrieves a teacher by their user ID
func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) {
query := `
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
FROM teachers
WHERE user_id = $1 AND is_active = true`
teacher := &models.Teacher{}
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get teacher by user ID: %w", err)
}
return teacher, nil
}
// AssignClassTeacher assigns a teacher to a class
func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error {
query := `
INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary`
_, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now())
if err != nil {
return fmt.Errorf("failed to assign class teacher: %w", err)
}
return nil
}
// ========================================
// Subject Management
// ========================================
// CreateSubject creates a new subject
func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) {
subject := &models.Subject{
ID: uuid.New(),
SchoolID: schoolID,
Name: name,
ShortName: shortName,
Color: color,
IsActive: true,
CreatedAt: time.Now(),
}
query := `
INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
subject.ID, subject.SchoolID, subject.Name, subject.ShortName,
subject.Color, subject.IsActive, subject.CreatedAt,
).Scan(&subject.ID)
if err != nil {
return nil, fmt.Errorf("failed to create subject: %w", err)
}
return subject, nil
}
// ListSubjects lists all subjects for a school
func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) {
query := `
SELECT id, school_id, name, short_name, color, is_active, created_at
FROM subjects
WHERE school_id = $1 AND is_active = true
ORDER BY name`
rows, err := s.db.Pool.Query(ctx, query, schoolID)
if err != nil {
return nil, fmt.Errorf("failed to list subjects: %w", err)
}
defer rows.Close()
var subjects []models.Subject
for rows.Next() {
var subject models.Subject
err := rows.Scan(
&subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName,
&subject.Color, &subject.IsActive, &subject.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan subject: %w", err)
}
subjects = append(subjects, subject)
}
return subjects, nil
}
// ========================================
// Parent Onboarding
// ========================================
// GenerateParentOnboardingToken generates a QR code token for parent onboarding
func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) {
// Generate secure random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
token := hex.EncodeToString(tokenBytes)
onboardingToken := &models.ParentOnboardingToken{
ID: uuid.New(),
SchoolID: schoolID,
ClassID: classID,
StudentID: studentID,
Token: token,
Role: role,
ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours
CreatedAt: time.Now(),
CreatedBy: createdByUserID,
}
query := `
INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID,
onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role,
onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy,
).Scan(&onboardingToken.ID)
if err != nil {
return nil, fmt.Errorf("failed to create onboarding token: %w", err)
}
return onboardingToken, nil
}
// ValidateOnboardingToken validates and retrieves info for an onboarding token
func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) {
query := `
SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by
FROM parent_onboarding_tokens
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`
onboardingToken := &models.ParentOnboardingToken{}
err := s.db.Pool.QueryRow(ctx, query, token).Scan(
&onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID,
&onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role,
&onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID,
&onboardingToken.CreatedAt, &onboardingToken.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("invalid or expired token: %w", err)
}
return onboardingToken, nil
}
// RedeemOnboardingToken marks a token as used and creates the parent account
func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error {
query := `
UPDATE parent_onboarding_tokens
SET used_at = NOW(), used_by_user_id = $1
WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()`
result, err := s.db.Pool.Exec(ctx, query, userID, token)
if err != nil {
return fmt.Errorf("failed to redeem token: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("token not found or already used")
}
return nil
}
// ========================================
// Helper Functions
// ========================================

View File

@@ -0,0 +1,357 @@
package services
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/google/uuid"
)
// ========================================
// Student Management
// ========================================
// CreateStudent creates a new student
func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) {
classID, err := uuid.Parse(req.ClassID)
if err != nil {
return nil, fmt.Errorf("invalid class ID: %w", err)
}
student := &models.Student{
ID: uuid.New(),
SchoolID: schoolID,
ClassID: classID,
StudentNumber: req.StudentNumber,
FirstName: req.FirstName,
LastName: req.LastName,
Gender: req.Gender,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if req.DateOfBirth != nil {
dob, err := time.Parse("2006-01-02", *req.DateOfBirth)
if err == nil {
student.DateOfBirth = &dob
}
}
query := `
INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id`
err = s.db.Pool.QueryRow(ctx, query,
student.ID, student.SchoolID, student.ClassID, student.StudentNumber,
student.FirstName, student.LastName, student.DateOfBirth, student.Gender,
student.IsActive, student.CreatedAt, student.UpdatedAt,
).Scan(&student.ID)
if err != nil {
return nil, fmt.Errorf("failed to create student: %w", err)
}
return student, nil
}
// GetStudent retrieves a student by ID
func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) {
query := `
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
FROM students
WHERE id = $1`
student := &models.Student{}
err := s.db.Pool.QueryRow(ctx, query, studentID).Scan(
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
&student.StudentNumber, &student.FirstName, &student.LastName,
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get student: %w", err)
}
return student, nil
}
// ListStudentsByClass lists all students in a class
func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) {
query := `
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
FROM students
WHERE class_id = $1 AND is_active = true
ORDER BY last_name, first_name`
rows, err := s.db.Pool.Query(ctx, query, classID)
if err != nil {
return nil, fmt.Errorf("failed to list students: %w", err)
}
defer rows.Close()
var students []models.Student
for rows.Next() {
var student models.Student
err := rows.Scan(
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
&student.StudentNumber, &student.FirstName, &student.LastName,
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan student: %w", err)
}
students = append(students, student)
}
return students, nil
}
// ========================================
// Teacher Management
// ========================================
// CreateTeacher creates a new teacher linked to a user account
func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) {
teacher := &models.Teacher{
ID: uuid.New(),
SchoolID: schoolID,
UserID: userID,
TeacherCode: teacherCode,
Title: title,
FirstName: firstName,
LastName: lastName,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode,
teacher.Title, teacher.FirstName, teacher.LastName,
teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt,
).Scan(&teacher.ID)
if err != nil {
return nil, fmt.Errorf("failed to create teacher: %w", err)
}
return teacher, nil
}
// GetTeacher retrieves a teacher by ID
func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) {
query := `
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
FROM teachers
WHERE id = $1`
teacher := &models.Teacher{}
err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan(
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get teacher: %w", err)
}
return teacher, nil
}
// GetTeacherByUserID retrieves a teacher by their user ID
func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) {
query := `
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
FROM teachers
WHERE user_id = $1 AND is_active = true`
teacher := &models.Teacher{}
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get teacher by user ID: %w", err)
}
return teacher, nil
}
// AssignClassTeacher assigns a teacher to a class
func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error {
query := `
INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary`
_, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now())
if err != nil {
return fmt.Errorf("failed to assign class teacher: %w", err)
}
return nil
}
// ========================================
// Subject Management
// ========================================
// CreateSubject creates a new subject
func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) {
subject := &models.Subject{
ID: uuid.New(),
SchoolID: schoolID,
Name: name,
ShortName: shortName,
Color: color,
IsActive: true,
CreatedAt: time.Now(),
}
query := `
INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
subject.ID, subject.SchoolID, subject.Name, subject.ShortName,
subject.Color, subject.IsActive, subject.CreatedAt,
).Scan(&subject.ID)
if err != nil {
return nil, fmt.Errorf("failed to create subject: %w", err)
}
return subject, nil
}
// ListSubjects lists all subjects for a school
func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) {
query := `
SELECT id, school_id, name, short_name, color, is_active, created_at
FROM subjects
WHERE school_id = $1 AND is_active = true
ORDER BY name`
rows, err := s.db.Pool.Query(ctx, query, schoolID)
if err != nil {
return nil, fmt.Errorf("failed to list subjects: %w", err)
}
defer rows.Close()
var subjects []models.Subject
for rows.Next() {
var subject models.Subject
err := rows.Scan(
&subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName,
&subject.Color, &subject.IsActive, &subject.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan subject: %w", err)
}
subjects = append(subjects, subject)
}
return subjects, nil
}
// ========================================
// Parent Onboarding
// ========================================
// GenerateParentOnboardingToken generates a QR code token for parent onboarding
func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) {
// Generate secure random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
token := hex.EncodeToString(tokenBytes)
onboardingToken := &models.ParentOnboardingToken{
ID: uuid.New(),
SchoolID: schoolID,
ClassID: classID,
StudentID: studentID,
Token: token,
Role: role,
ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours
CreatedAt: time.Now(),
CreatedBy: createdByUserID,
}
query := `
INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
err := s.db.Pool.QueryRow(ctx, query,
onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID,
onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role,
onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy,
).Scan(&onboardingToken.ID)
if err != nil {
return nil, fmt.Errorf("failed to create onboarding token: %w", err)
}
return onboardingToken, nil
}
// ValidateOnboardingToken validates and retrieves info for an onboarding token
func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) {
query := `
SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by
FROM parent_onboarding_tokens
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`
onboardingToken := &models.ParentOnboardingToken{}
err := s.db.Pool.QueryRow(ctx, query, token).Scan(
&onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID,
&onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role,
&onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID,
&onboardingToken.CreatedAt, &onboardingToken.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("invalid or expired token: %w", err)
}
return onboardingToken, nil
}
// RedeemOnboardingToken marks a token as used and creates the parent account
func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error {
query := `
UPDATE parent_onboarding_tokens
SET used_at = NOW(), used_by_user_id = $1
WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()`
result, err := s.db.Pool.Exec(ctx, query, userID, token)
if err != nil {
return fmt.Errorf("failed to redeem token: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("token not found or already used")
}
return nil
}