[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:
File diff suppressed because it is too large
Load Diff
307
consent-service/internal/database/migrate_core.go
Normal file
307
consent-service/internal/database/migrate_core.go
Normal 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
|
||||
}
|
||||
267
consent-service/internal/database/migrate_dsr.go
Normal file
267
consent-service/internal/database/migrate_dsr.go
Normal 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
|
||||
}
|
||||
114
consent-service/internal/database/migrate_email.go
Normal file
114
consent-service/internal/database/migrate_email.go
Normal 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
|
||||
}
|
||||
171
consent-service/internal/database/migrate_oauth.go
Normal file
171
consent-service/internal/database/migrate_oauth.go
Normal 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
|
||||
}
|
||||
182
consent-service/internal/database/migrate_school.go
Normal file
182
consent-service/internal/database/migrate_school.go
Normal 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)
|
||||
}
|
||||
346
consent-service/internal/database/migrate_school_ext.go
Normal file
346
consent-service/internal/database/migrate_school_ext.go
Normal 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
|
||||
}
|
||||
455
consent-service/internal/handlers/admin_approval.go
Normal file
455
consent-service/internal/handlers/admin_approval.go
Normal 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(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language,
|
||||
¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status,
|
||||
¤tVersion.CreatedAt, ¤tVersion.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})
|
||||
}
|
||||
391
consent-service/internal/handlers/admin_documents.go
Normal file
391
consent-service/internal/handlers/admin_documents.go
Normal 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,
|
||||
})
|
||||
}
|
||||
319
consent-service/internal/handlers/admin_operations.go
Normal file
319
consent-service/internal/handlers/admin_operations.go
Normal 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})
|
||||
}
|
||||
265
consent-service/internal/handlers/banner_config_handlers.go
Normal file
265
consent-service/internal/handlers/banner_config_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
244
consent-service/internal/handlers/consents_public.go
Normal file
244
consent-service/internal/handlers/consents_public.go
Normal 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"})
|
||||
}
|
||||
158
consent-service/internal/handlers/cookies_public.go
Normal file
158
consent-service/internal/handlers/cookies_public.go
Normal 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})
|
||||
}
|
||||
90
consent-service/internal/handlers/documents_public.go
Normal file
90
consent-service/internal/handlers/documents_public.go
Normal 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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
388
consent-service/internal/handlers/dsr_handlers_admin.go
Normal file
388
consent-service/internal/handlers/dsr_handlers_admin.go
Normal 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})
|
||||
}
|
||||
195
consent-service/internal/handlers/dsr_handlers_ops.go
Normal file
195
consent-service/internal/handlers/dsr_handlers_ops.go
Normal 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"})
|
||||
}
|
||||
264
consent-service/internal/handlers/dsr_handlers_templates.go
Normal file
264
consent-service/internal/handlers/dsr_handlers_templates.go
Normal 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})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
276
consent-service/internal/handlers/email_template_ops_handlers.go
Normal file
276
consent-service/internal/handlers/email_template_ops_handlers.go
Normal 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"})
|
||||
}
|
||||
168
consent-service/internal/handlers/gdpr.go
Normal file
168
consent-service/internal/handlers/gdpr.go
Normal 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
372
consent-service/internal/handlers/oauth_2fa_handlers.go
Normal file
372
consent-service/internal/handlers/oauth_2fa_handlers.go
Normal 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
// ========================================
|
||||
|
||||
243
consent-service/internal/handlers/school_attendance_handlers.go
Normal file
243
consent-service/internal/handlers/school_attendance_handlers.go
Normal 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)
|
||||
}
|
||||
303
consent-service/internal/handlers/school_grade_handlers.go
Normal file
303
consent-service/internal/handlers/school_grade_handlers.go
Normal 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"})
|
||||
}
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
237
consent-service/internal/models/consent.go
Normal file
237
consent-service/internal/models/consent.go
Normal 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"`
|
||||
}
|
||||
118
consent-service/internal/models/cookies_notifications.go
Normal file
118
consent-service/internal/models/cookies_notifications.go
Normal 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"`
|
||||
}
|
||||
403
consent-service/internal/models/dsr.go
Normal file
403
consent-service/internal/models/dsr.go
Normal 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
|
||||
}
|
||||
}
|
||||
191
consent-service/internal/models/email_templates.go
Normal file
191
consent-service/internal/models/email_templates.go
Normal 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
103
consent-service/internal/models/oauth.go
Normal file
103
consent-service/internal/models/oauth.go
Normal 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"`
|
||||
}
|
||||
187
consent-service/internal/models/school.go
Normal file
187
consent-service/internal/models/school.go
Normal 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"`
|
||||
}
|
||||
382
consent-service/internal/models/school_operations.go
Normal file
382
consent-service/internal/models/school_operations.go
Normal 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"`
|
||||
}
|
||||
195
consent-service/internal/models/user.go
Normal file
195
consent-service/internal/models/user.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
280
consent-service/internal/services/attendance_service_ops.go
Normal file
280
consent-service/internal/services/attendance_service_ops.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
196
consent-service/internal/services/auth_service_sessions.go
Normal file
196
consent-service/internal/services/auth_service_sessions.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
208
consent-service/internal/services/dsr_service_comms.go
Normal file
208
consent-service/internal/services/dsr_service_comms.go
Normal 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)
|
||||
}
|
||||
278
consent-service/internal/services/dsr_service_dashboard.go
Normal file
278
consent-service/internal/services/dsr_service_dashboard.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
310
consent-service/internal/services/email_service_templates.go
Normal file
310
consent-service/internal/services/email_service_templates.go
Normal 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"
|
||||
}
|
||||
174
consent-service/internal/services/email_template_approval.go
Normal file
174
consent-service/internal/services/email_template_approval.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
273
consent-service/internal/services/email_template_render.go
Normal file
273
consent-service/internal/services/email_template_render.go
Normal 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
300
consent-service/internal/services/email_template_settings.go
Normal file
300
consent-service/internal/services/email_template_settings.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
219
consent-service/internal/services/grade_service_ops.go
Normal file
219
consent-service/internal/services/grade_service_ops.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
|
||||
290
consent-service/internal/services/jitsi/jitsi_service_helpers.go
Normal file
290
consent-service/internal/services/jitsi/jitsi_service_helpers.go
Normal 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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
308
consent-service/internal/services/oauth_token_service.go
Normal file
308
consent-service/internal/services/oauth_token_service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
357
consent-service/internal/services/school_service_members.go
Normal file
357
consent-service/internal/services/school_service_members.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user