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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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