[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:
@@ -235,271 +235,3 @@ func (s *AttendanceService) GetStudentAttendance(ctx context.Context, studentID
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Absence Reports (Parent-initiated)
|
||||
// ========================================
|
||||
|
||||
// ReportAbsence allows parents to report a student's absence
|
||||
func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) {
|
||||
studentID, err := uuid.Parse(req.StudentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid student ID: %w", err)
|
||||
}
|
||||
|
||||
startDate, err := time.Parse("2006-01-02", req.StartDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid start date format: %w", err)
|
||||
}
|
||||
|
||||
endDate, err := time.Parse("2006-01-02", req.EndDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid end date format: %w", err)
|
||||
}
|
||||
|
||||
report := &models.AbsenceReport{
|
||||
ID: uuid.New(),
|
||||
StudentID: studentID,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Reason: req.Reason,
|
||||
ReasonCategory: req.ReasonCategory,
|
||||
Status: "reported",
|
||||
ReportedBy: reportedByUserID,
|
||||
ReportedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`
|
||||
|
||||
err = s.db.Pool.QueryRow(ctx, query,
|
||||
report.ID, report.StudentID, report.StartDate, report.EndDate,
|
||||
report.Reason, report.ReasonCategory, report.Status,
|
||||
report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt,
|
||||
).Scan(&report.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create absence report: %w", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// ConfirmAbsence allows teachers to confirm/excuse an absence
|
||||
func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error {
|
||||
query := `
|
||||
UPDATE absence_reports
|
||||
SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $3`
|
||||
|
||||
result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to confirm absence: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("absence report not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAbsenceReports gets absence reports for a student
|
||||
func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) {
|
||||
query := `
|
||||
SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at
|
||||
FROM absence_reports
|
||||
WHERE student_id = $1
|
||||
ORDER BY start_date DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, studentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absence reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reports []models.AbsenceReport
|
||||
for rows.Next() {
|
||||
var report models.AbsenceReport
|
||||
err := rows.Scan(
|
||||
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
|
||||
&report.Reason, &report.ReasonCategory, &report.Status,
|
||||
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
|
||||
&report.MedicalCertificate, &report.CertificateUploaded,
|
||||
&report.MatrixNotificationSent, &report.EmailNotificationSent,
|
||||
&report.CreatedAt, &report.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan absence report: %w", err)
|
||||
}
|
||||
reports = append(reports, report)
|
||||
}
|
||||
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
// GetPendingAbsenceReports gets all unconfirmed absence reports for a class
|
||||
func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) {
|
||||
query := `
|
||||
SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at
|
||||
FROM absence_reports ar
|
||||
JOIN students s ON ar.student_id = s.id
|
||||
WHERE s.class_id = $1 AND ar.status = 'reported'
|
||||
ORDER BY ar.start_date DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, classID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get pending absence reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reports []models.AbsenceReport
|
||||
for rows.Next() {
|
||||
var report models.AbsenceReport
|
||||
err := rows.Scan(
|
||||
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
|
||||
&report.Reason, &report.ReasonCategory, &report.Status,
|
||||
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
|
||||
&report.MedicalCertificate, &report.CertificateUploaded,
|
||||
&report.MatrixNotificationSent, &report.EmailNotificationSent,
|
||||
&report.CreatedAt, &report.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan absence report: %w", err)
|
||||
}
|
||||
reports = append(reports, report)
|
||||
}
|
||||
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Attendance Statistics
|
||||
// ========================================
|
||||
|
||||
// GetStudentAttendanceStats gets attendance statistics for a student
|
||||
func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total_records,
|
||||
COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count,
|
||||
COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count,
|
||||
COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count,
|
||||
COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count
|
||||
FROM attendance_records ar
|
||||
JOIN timetable_slots ts ON ar.slot_id = ts.id
|
||||
JOIN schools sch ON ts.school_id = sch.id
|
||||
JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2
|
||||
WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date`
|
||||
|
||||
var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan(
|
||||
&totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attendance stats: %w", err)
|
||||
}
|
||||
|
||||
var attendanceRate float64
|
||||
if totalRecords > 0 {
|
||||
attendanceRate = float64(presentCount) / float64(totalRecords) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_records": totalRecords,
|
||||
"present_count": presentCount,
|
||||
"absent_count": absentCount,
|
||||
"unexcused_count": unexcusedCount,
|
||||
"late_count": lateCount,
|
||||
"attendance_rate": attendanceRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Notifications
|
||||
// ========================================
|
||||
|
||||
func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) {
|
||||
if s.matrix == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get student info
|
||||
var studentFirstName, studentLastName, matrixDMRoom string
|
||||
err := s.db.Pool.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, matrix_dm_room
|
||||
FROM students
|
||||
WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
|
||||
if err != nil || matrixDMRoom == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get slot info
|
||||
var slotNumber int
|
||||
err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
studentName := studentFirstName + " " + studentLastName
|
||||
dateStr := record.Date.Format("02.01.2006")
|
||||
|
||||
// Send Matrix notification
|
||||
err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to send absence notification: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update notification status
|
||||
s.db.Pool.Exec(ctx, `
|
||||
UPDATE attendance_records
|
||||
SET updated_at = NOW()
|
||||
WHERE id = $1`, record.ID)
|
||||
|
||||
// Log the notification
|
||||
s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber)
|
||||
}
|
||||
|
||||
func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) {
|
||||
record := &models.AttendanceRecord{
|
||||
StudentID: studentID,
|
||||
Date: date,
|
||||
SlotID: slotID,
|
||||
}
|
||||
s.notifyParentsOfAbsence(ctx, record)
|
||||
}
|
||||
|
||||
func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) {
|
||||
// Get parent IDs for this student
|
||||
query := `
|
||||
SELECT p.id
|
||||
FROM parents p
|
||||
JOIN student_parents sp ON p.id = sp.parent_id
|
||||
JOIN attendance_records ar ON sp.student_id = ar.student_id
|
||||
WHERE ar.id = $1`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, recordID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber)
|
||||
|
||||
for rows.Next() {
|
||||
var parentID uuid.UUID
|
||||
if err := rows.Scan(&parentID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert notification log
|
||||
s.db.Pool.Exec(ctx, `
|
||||
INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at)
|
||||
VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`,
|
||||
uuid.New(), recordID, parentID, message)
|
||||
}
|
||||
}
|
||||
|
||||
280
consent-service/internal/services/attendance_service_ops.go
Normal file
280
consent-service/internal/services/attendance_service_ops.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Absence Reports (Parent-initiated)
|
||||
// ========================================
|
||||
|
||||
// ReportAbsence allows parents to report a student's absence
|
||||
func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) {
|
||||
studentID, err := uuid.Parse(req.StudentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid student ID: %w", err)
|
||||
}
|
||||
|
||||
startDate, err := time.Parse("2006-01-02", req.StartDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid start date format: %w", err)
|
||||
}
|
||||
|
||||
endDate, err := time.Parse("2006-01-02", req.EndDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid end date format: %w", err)
|
||||
}
|
||||
|
||||
report := &models.AbsenceReport{
|
||||
ID: uuid.New(),
|
||||
StudentID: studentID,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Reason: req.Reason,
|
||||
ReasonCategory: req.ReasonCategory,
|
||||
Status: "reported",
|
||||
ReportedBy: reportedByUserID,
|
||||
ReportedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`
|
||||
|
||||
err = s.db.Pool.QueryRow(ctx, query,
|
||||
report.ID, report.StudentID, report.StartDate, report.EndDate,
|
||||
report.Reason, report.ReasonCategory, report.Status,
|
||||
report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt,
|
||||
).Scan(&report.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create absence report: %w", err)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// ConfirmAbsence allows teachers to confirm/excuse an absence
|
||||
func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error {
|
||||
query := `
|
||||
UPDATE absence_reports
|
||||
SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $3`
|
||||
|
||||
result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to confirm absence: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("absence report not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAbsenceReports gets absence reports for a student
|
||||
func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) {
|
||||
query := `
|
||||
SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at
|
||||
FROM absence_reports
|
||||
WHERE student_id = $1
|
||||
ORDER BY start_date DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, studentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absence reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reports []models.AbsenceReport
|
||||
for rows.Next() {
|
||||
var report models.AbsenceReport
|
||||
err := rows.Scan(
|
||||
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
|
||||
&report.Reason, &report.ReasonCategory, &report.Status,
|
||||
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
|
||||
&report.MedicalCertificate, &report.CertificateUploaded,
|
||||
&report.MatrixNotificationSent, &report.EmailNotificationSent,
|
||||
&report.CreatedAt, &report.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan absence report: %w", err)
|
||||
}
|
||||
reports = append(reports, report)
|
||||
}
|
||||
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
// GetPendingAbsenceReports gets all unconfirmed absence reports for a class
|
||||
func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) {
|
||||
query := `
|
||||
SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at
|
||||
FROM absence_reports ar
|
||||
JOIN students s ON ar.student_id = s.id
|
||||
WHERE s.class_id = $1 AND ar.status = 'reported'
|
||||
ORDER BY ar.start_date DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, classID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get pending absence reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reports []models.AbsenceReport
|
||||
for rows.Next() {
|
||||
var report models.AbsenceReport
|
||||
err := rows.Scan(
|
||||
&report.ID, &report.StudentID, &report.StartDate, &report.EndDate,
|
||||
&report.Reason, &report.ReasonCategory, &report.Status,
|
||||
&report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt,
|
||||
&report.MedicalCertificate, &report.CertificateUploaded,
|
||||
&report.MatrixNotificationSent, &report.EmailNotificationSent,
|
||||
&report.CreatedAt, &report.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan absence report: %w", err)
|
||||
}
|
||||
reports = append(reports, report)
|
||||
}
|
||||
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Attendance Statistics
|
||||
// ========================================
|
||||
|
||||
// GetStudentAttendanceStats gets attendance statistics for a student
|
||||
func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total_records,
|
||||
COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count,
|
||||
COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count,
|
||||
COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count,
|
||||
COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count
|
||||
FROM attendance_records ar
|
||||
JOIN timetable_slots ts ON ar.slot_id = ts.id
|
||||
JOIN schools sch ON ts.school_id = sch.id
|
||||
JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2
|
||||
WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date`
|
||||
|
||||
var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan(
|
||||
&totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attendance stats: %w", err)
|
||||
}
|
||||
|
||||
var attendanceRate float64
|
||||
if totalRecords > 0 {
|
||||
attendanceRate = float64(presentCount) / float64(totalRecords) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_records": totalRecords,
|
||||
"present_count": presentCount,
|
||||
"absent_count": absentCount,
|
||||
"unexcused_count": unexcusedCount,
|
||||
"late_count": lateCount,
|
||||
"attendance_rate": attendanceRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Notifications
|
||||
// ========================================
|
||||
|
||||
func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) {
|
||||
if s.matrix == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get student info
|
||||
var studentFirstName, studentLastName, matrixDMRoom string
|
||||
err := s.db.Pool.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, matrix_dm_room
|
||||
FROM students
|
||||
WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
|
||||
if err != nil || matrixDMRoom == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get slot info
|
||||
var slotNumber int
|
||||
err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
studentName := studentFirstName + " " + studentLastName
|
||||
dateStr := record.Date.Format("02.01.2006")
|
||||
|
||||
// Send Matrix notification
|
||||
err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to send absence notification: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update notification status
|
||||
s.db.Pool.Exec(ctx, `
|
||||
UPDATE attendance_records
|
||||
SET updated_at = NOW()
|
||||
WHERE id = $1`, record.ID)
|
||||
|
||||
// Log the notification
|
||||
s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber)
|
||||
}
|
||||
|
||||
func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) {
|
||||
record := &models.AttendanceRecord{
|
||||
StudentID: studentID,
|
||||
Date: date,
|
||||
SlotID: slotID,
|
||||
}
|
||||
s.notifyParentsOfAbsence(ctx, record)
|
||||
}
|
||||
|
||||
func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) {
|
||||
// Get parent IDs for this student
|
||||
query := `
|
||||
SELECT p.id
|
||||
FROM parents p
|
||||
JOIN student_parents sp ON p.id = sp.parent_id
|
||||
JOIN attendance_records ar ON sp.student_id = ar.student_id
|
||||
WHERE ar.id = $1`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, recordID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber)
|
||||
|
||||
for rows.Next() {
|
||||
var parentID uuid.UUID
|
||||
if err := rows.Scan(&parentID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert notification log
|
||||
s.db.Pool.Exec(ctx, `
|
||||
INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at)
|
||||
VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`,
|
||||
uuid.New(), recordID, parentID, message)
|
||||
}
|
||||
}
|
||||
@@ -383,186 +383,3 @@ func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken creates a password reset token
|
||||
func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) {
|
||||
var userID uuid.UUID
|
||||
err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID)
|
||||
if err != nil {
|
||||
// Don't reveal if user exists
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
token, err := s.GenerateSecureToken(32)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
`, userID, token, time.Now().Add(time.Hour), ipAddress)
|
||||
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create reset token: %w", err)
|
||||
}
|
||||
|
||||
return token, &userID, nil
|
||||
}
|
||||
|
||||
// ResetPassword resets a user's password using a reset token
|
||||
func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||||
var tokenID uuid.UUID
|
||||
var userID uuid.UUID
|
||||
var expiresAt time.Time
|
||||
var usedAt *time.Time
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, user_id, expires_at, used_at FROM password_reset_tokens
|
||||
WHERE token = $1
|
||||
`, token).Scan(&tokenID, &userID, &expiresAt, &usedAt)
|
||||
|
||||
if err != nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if usedAt != nil || expiresAt.Before(time.Now()) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
passwordHash, err := s.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
_, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update token: %w", err)
|
||||
}
|
||||
|
||||
// Update password
|
||||
_, err = s.db.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2
|
||||
`, passwordHash, userID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Revoke all sessions for security
|
||||
_, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to revoke sessions: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password (requires current password)
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
var passwordHash *string
|
||||
err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
newPasswordHash, err := s.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) {
|
||||
var user models.User
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, email, name, role, email_verified, email_verified_at, account_status,
|
||||
last_login_at, created_at, updated_at
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt,
|
||||
&user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates a user's profile
|
||||
func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update profile: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetActiveSessions retrieves all active sessions for a user
|
||||
func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at
|
||||
FROM user_sessions
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY last_activity_at DESC
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []models.UserSession
|
||||
for rows.Next() {
|
||||
var session models.UserSession
|
||||
err := rows.Scan(
|
||||
&session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress,
|
||||
&session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session: %w", err)
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// RevokeSession revokes a specific session
|
||||
func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||
`, sessionID, userID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revoke session: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return errors.New("session not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout revokes a session by refresh token
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
|
||||
tokenHash := s.HashToken(refreshToken)
|
||||
_, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
196
consent-service/internal/services/auth_service_sessions.go
Normal file
196
consent-service/internal/services/auth_service_sessions.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
)
|
||||
|
||||
// CreatePasswordResetToken creates a password reset token
|
||||
func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) {
|
||||
var userID uuid.UUID
|
||||
err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID)
|
||||
if err != nil {
|
||||
// Don't reveal if user exists
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
token, err := s.GenerateSecureToken(32)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
`, userID, token, time.Now().Add(time.Hour), ipAddress)
|
||||
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create reset token: %w", err)
|
||||
}
|
||||
|
||||
return token, &userID, nil
|
||||
}
|
||||
|
||||
// ResetPassword resets a user's password using a reset token
|
||||
func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||||
var tokenID uuid.UUID
|
||||
var userID uuid.UUID
|
||||
var expiresAt time.Time
|
||||
var usedAt *time.Time
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, user_id, expires_at, used_at FROM password_reset_tokens
|
||||
WHERE token = $1
|
||||
`, token).Scan(&tokenID, &userID, &expiresAt, &usedAt)
|
||||
|
||||
if err != nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if usedAt != nil || expiresAt.Before(time.Now()) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
passwordHash, err := s.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
_, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update token: %w", err)
|
||||
}
|
||||
|
||||
// Update password
|
||||
_, err = s.db.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2
|
||||
`, passwordHash, userID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Revoke all sessions for security
|
||||
_, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to revoke sessions: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password (requires current password)
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
var passwordHash *string
|
||||
err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
newPasswordHash, err := s.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) {
|
||||
var user models.User
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, email, name, role, email_verified, email_verified_at, account_status,
|
||||
last_login_at, created_at, updated_at
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt,
|
||||
&user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates a user's profile
|
||||
func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) {
|
||||
_, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update profile: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetActiveSessions retrieves all active sessions for a user
|
||||
func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at
|
||||
FROM user_sessions
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY last_activity_at DESC
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []models.UserSession
|
||||
for rows.Next() {
|
||||
var session models.UserSession
|
||||
err := rows.Scan(
|
||||
&session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress,
|
||||
&session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session: %w", err)
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// RevokeSession revokes a specific session
|
||||
func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
|
||||
`, sessionID, userID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to revoke session: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return errors.New("session not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout revokes a session by refresh token
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
|
||||
tokenHash := s.HashToken(refreshToken)
|
||||
_, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash)
|
||||
return err
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
@@ -367,29 +366,6 @@ func (s *DSRService) AssignRequest(ctx context.Context, id uuid.UUID, assigneeID
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtendDeadline extends the deadline for a DSR
|
||||
func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error {
|
||||
// Default extension is 2 months (60 days) per Art. 12(3)
|
||||
if days <= 0 {
|
||||
days = 60
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE data_subject_requests
|
||||
SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL,
|
||||
extension_reason = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, days, reason, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extend deadline: %w", err)
|
||||
}
|
||||
|
||||
s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteRequest marks a DSR as completed
|
||||
func (s *DSRService) CompleteRequest(ctx context.Context, id uuid.UUID, summary string, resultData map[string]interface{}, completedBy uuid.UUID) error {
|
||||
resultJSON, _ := json.Marshal(resultData)
|
||||
@@ -470,352 +446,7 @@ func (s *DSRService) CancelRequest(ctx context.Context, id uuid.UUID, cancelledB
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDashboardStats returns statistics for the admin dashboard
|
||||
func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) {
|
||||
stats := &models.DSRDashboardStats{
|
||||
ByType: make(map[string]int),
|
||||
ByStatus: make(map[string]int),
|
||||
}
|
||||
|
||||
// Total requests
|
||||
s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests)
|
||||
|
||||
// Pending requests (not completed, rejected, or cancelled)
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM data_subject_requests
|
||||
WHERE status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`).Scan(&stats.PendingRequests)
|
||||
|
||||
// Overdue requests
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) < NOW()
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`).Scan(&stats.OverdueRequests)
|
||||
|
||||
// Completed this month
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM data_subject_requests
|
||||
WHERE status = 'completed'
|
||||
AND completed_at >= DATE_TRUNC('month', NOW())
|
||||
`).Scan(&stats.CompletedThisMonth)
|
||||
|
||||
// Average processing days
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0)
|
||||
FROM data_subject_requests WHERE status = 'completed'
|
||||
`).Scan(&stats.AverageProcessingDays)
|
||||
|
||||
// Count by type
|
||||
rows, _ := s.pool.Query(ctx, `
|
||||
SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type
|
||||
`)
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var count int
|
||||
rows.Scan(&t, &count)
|
||||
stats.ByType[t] = count
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Count by status
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status
|
||||
`)
|
||||
for rows.Next() {
|
||||
var s string
|
||||
var count int
|
||||
rows.Scan(&s, &count)
|
||||
stats.ByStatus[s] = count
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Upcoming deadlines (next 7 days)
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, status, requester_email, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days'
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
ORDER BY deadline_at ASC LIMIT 10
|
||||
`)
|
||||
for rows.Next() {
|
||||
var dsr models.DataSubjectRequest
|
||||
rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt)
|
||||
stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetStatusHistory retrieves the status history for a DSR
|
||||
func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at
|
||||
FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC
|
||||
`, requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query status history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []models.DSRStatusHistory
|
||||
for rows.Next() {
|
||||
var h models.DSRStatusHistory
|
||||
var metadataJSON []byte
|
||||
err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal(metadataJSON, &h.Metadata)
|
||||
history = append(history, h)
|
||||
}
|
||||
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetCommunications retrieves communications for a DSR
|
||||
func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, request_id, direction, channel, communication_type, template_version_id,
|
||||
subject, body_html, body_text, recipient_email, sent_at, error_message,
|
||||
attachments, created_at, created_by
|
||||
FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC
|
||||
`, requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query communications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comms []models.DSRCommunication
|
||||
for rows.Next() {
|
||||
var c models.DSRCommunication
|
||||
var attachmentsJSON []byte
|
||||
err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType,
|
||||
&c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail,
|
||||
&c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal(attachmentsJSON, &c.Attachments)
|
||||
comms = append(comms, c)
|
||||
}
|
||||
|
||||
return comms, nil
|
||||
}
|
||||
|
||||
// SendCommunication sends a communication for a DSR
|
||||
func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error {
|
||||
// Get DSR details
|
||||
dsr, err := s.GetByID(ctx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get template if specified
|
||||
var subject, bodyHTML, bodyText string
|
||||
if req.TemplateVersionID != nil {
|
||||
templateVersionID, _ := uuid.Parse(*req.TemplateVersionID)
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published'
|
||||
`, templateVersionID).Scan(&subject, &bodyHTML, &bodyText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("template version not found or not published: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom content if provided
|
||||
if req.CustomSubject != nil {
|
||||
subject = *req.CustomSubject
|
||||
}
|
||||
if req.CustomBody != nil {
|
||||
bodyHTML = *req.CustomBody
|
||||
bodyText = stripHTML(*req.CustomBody)
|
||||
}
|
||||
|
||||
// Replace variables
|
||||
variables := map[string]string{
|
||||
"requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"),
|
||||
"request_number": dsr.RequestNumber,
|
||||
"request_type_de": dsr.RequestType.Label(),
|
||||
"request_date": dsr.CreatedAt.Format("02.01.2006"),
|
||||
"deadline_date": dsr.DeadlineAt.Format("02.01.2006"),
|
||||
}
|
||||
for k, v := range req.Variables {
|
||||
variables[k] = v
|
||||
}
|
||||
subject = replaceVariables(subject, variables)
|
||||
bodyHTML = replaceVariables(bodyHTML, variables)
|
||||
bodyText = replaceVariables(bodyText, variables)
|
||||
|
||||
// Send email
|
||||
if s.emailService != nil {
|
||||
err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
|
||||
template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by)
|
||||
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
|
||||
dsr.RequesterEmail, err.Error(), sentBy)
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log communication
|
||||
now := time.Now()
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
|
||||
template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by)
|
||||
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
|
||||
dsr.RequesterEmail, now, sentBy)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// InitErasureExceptionChecks initializes exception checks for an erasure request
|
||||
func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error {
|
||||
exceptions := []struct {
|
||||
Type string
|
||||
Description string
|
||||
}{
|
||||
{models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"},
|
||||
{models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"},
|
||||
{models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"},
|
||||
{models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"},
|
||||
{models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"},
|
||||
}
|
||||
|
||||
for _, exc := range exceptions {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO dsr_exception_checks (request_id, exception_type, description)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
|
||||
`, requestID, exc.Type, exc.Description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create exception check: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExceptionChecks retrieves exception checks for a DSR
|
||||
func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at
|
||||
FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at
|
||||
`, requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query exception checks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var checks []models.DSRExceptionCheck
|
||||
for rows.Next() {
|
||||
var c models.DSRExceptionCheck
|
||||
err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies,
|
||||
&c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
checks = append(checks, c)
|
||||
}
|
||||
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
// UpdateExceptionCheck updates an exception check
|
||||
func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE dsr_exception_checks
|
||||
SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW()
|
||||
WHERE id = $4
|
||||
`, applies, notes, checkedBy, checkID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ProcessDeadlines checks for approaching and overdue deadlines
|
||||
func (s *DSRService) ProcessDeadlines(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
|
||||
// Find requests with deadlines in 3 days
|
||||
threeDaysAhead := now.AddDate(0, 0, 3)
|
||||
rows, _ := s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, assigned_to, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`, now, threeDaysAhead)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var requestNumber, requestType string
|
||||
var assignedTo *uuid.UUID
|
||||
var deadline time.Time
|
||||
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
|
||||
|
||||
// Notify assigned user or all DPOs
|
||||
if assignedTo != nil {
|
||||
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3)
|
||||
} else {
|
||||
s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Find requests with deadlines in 1 day
|
||||
oneDayAhead := now.AddDate(0, 0, 1)
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, assigned_to, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`, now, oneDayAhead)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var requestNumber, requestType string
|
||||
var assignedTo *uuid.UUID
|
||||
var deadline time.Time
|
||||
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
|
||||
|
||||
if assignedTo != nil {
|
||||
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1)
|
||||
} else {
|
||||
s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Find overdue requests
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, assigned_to, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) < $1
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`, now)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var requestNumber, requestType string
|
||||
var assignedTo *uuid.UUID
|
||||
var deadline time.Time
|
||||
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
|
||||
|
||||
// Notify all DPOs for overdue
|
||||
s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline)
|
||||
|
||||
// Log to audit
|
||||
s.pool.Exec(ctx, `
|
||||
INSERT INTO consent_audit_log (action, entity_type, entity_id, details)
|
||||
VALUES ('dsr_overdue', 'dsr', $1, $2)
|
||||
`, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339)))
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID, fromStatus *models.DSRStatus, toStatus models.DSRStatus, changedBy *uuid.UUID, comment string) {
|
||||
s.pool.Exec(ctx, `
|
||||
@@ -824,62 +455,6 @@ func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID
|
||||
`, requestID, fromStatus, toStatus, changedBy, comment)
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
// Notify all DPOs
|
||||
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
rows.Scan(&userID)
|
||||
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived,
|
||||
"Neue Betroffenenanfrage",
|
||||
fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber),
|
||||
map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
dsr, _ := s.GetByID(ctx, dsrID)
|
||||
if dsr != nil {
|
||||
s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned,
|
||||
"Betroffenenanfrage zugewiesen",
|
||||
fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber),
|
||||
map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
|
||||
fmt.Sprintf("Fristwarnung: %s", requestNumber),
|
||||
fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")),
|
||||
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft})
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
rows.Scan(&userID)
|
||||
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
|
||||
fmt.Sprintf("%s: %s", message, requestNumber),
|
||||
fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")),
|
||||
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline})
|
||||
}
|
||||
}
|
||||
|
||||
func isValidRequestType(rt models.DSRRequestType) bool {
|
||||
switch rt {
|
||||
case models.DSRTypeAccess, models.DSRTypeRectification, models.DSRTypeErasure,
|
||||
@@ -891,12 +466,12 @@ func isValidRequestType(rt models.DSRRequestType) bool {
|
||||
|
||||
func isValidStatusTransition(from, to models.DSRStatus) bool {
|
||||
validTransitions := map[models.DSRStatus][]models.DSRStatus{
|
||||
models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
|
||||
models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
|
||||
models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled},
|
||||
models.DSRStatusCompleted: {},
|
||||
models.DSRStatusRejected: {},
|
||||
models.DSRStatusCancelled: {},
|
||||
models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
|
||||
models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled},
|
||||
models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled},
|
||||
models.DSRStatusCompleted: {},
|
||||
models.DSRStatusRejected: {},
|
||||
models.DSRStatusCancelled: {},
|
||||
}
|
||||
|
||||
allowed, exists := validTransitions[from]
|
||||
@@ -910,38 +485,3 @@ func isValidStatusTransition(from, to models.DSRStatus) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringOrDefault(s *string, def string) string {
|
||||
if s != nil {
|
||||
return *s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func replaceVariables(text string, variables map[string]string) string {
|
||||
for k, v := range variables {
|
||||
text = strings.ReplaceAll(text, "{{"+k+"}}", v)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func stripHTML(html string) string {
|
||||
// Simple HTML stripping - in production use a proper library
|
||||
text := strings.ReplaceAll(html, "<br>", "\n")
|
||||
text = strings.ReplaceAll(text, "<br/>", "\n")
|
||||
text = strings.ReplaceAll(text, "<br />", "\n")
|
||||
text = strings.ReplaceAll(text, "</p>", "\n\n")
|
||||
// Remove all remaining tags
|
||||
for {
|
||||
start := strings.Index(text, "<")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(text[start:], ">")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
text = text[:start] + text[start+end+1:]
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
208
consent-service/internal/services/dsr_service_comms.go
Normal file
208
consent-service/internal/services/dsr_service_comms.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetCommunications retrieves communications for a DSR
|
||||
func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, request_id, direction, channel, communication_type, template_version_id,
|
||||
subject, body_html, body_text, recipient_email, sent_at, error_message,
|
||||
attachments, created_at, created_by
|
||||
FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC
|
||||
`, requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query communications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comms []models.DSRCommunication
|
||||
for rows.Next() {
|
||||
var c models.DSRCommunication
|
||||
var attachmentsJSON []byte
|
||||
err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType,
|
||||
&c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail,
|
||||
&c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal(attachmentsJSON, &c.Attachments)
|
||||
comms = append(comms, c)
|
||||
}
|
||||
|
||||
return comms, nil
|
||||
}
|
||||
|
||||
// SendCommunication sends a communication for a DSR
|
||||
func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error {
|
||||
// Get DSR details
|
||||
dsr, err := s.GetByID(ctx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get template if specified
|
||||
var subject, bodyHTML, bodyText string
|
||||
if req.TemplateVersionID != nil {
|
||||
templateVersionID, _ := uuid.Parse(*req.TemplateVersionID)
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published'
|
||||
`, templateVersionID).Scan(&subject, &bodyHTML, &bodyText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("template version not found or not published: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom content if provided
|
||||
if req.CustomSubject != nil {
|
||||
subject = *req.CustomSubject
|
||||
}
|
||||
if req.CustomBody != nil {
|
||||
bodyHTML = *req.CustomBody
|
||||
bodyText = stripHTML(*req.CustomBody)
|
||||
}
|
||||
|
||||
// Replace variables
|
||||
variables := map[string]string{
|
||||
"requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"),
|
||||
"request_number": dsr.RequestNumber,
|
||||
"request_type_de": dsr.RequestType.Label(),
|
||||
"request_date": dsr.CreatedAt.Format("02.01.2006"),
|
||||
"deadline_date": dsr.DeadlineAt.Format("02.01.2006"),
|
||||
}
|
||||
for k, v := range req.Variables {
|
||||
variables[k] = v
|
||||
}
|
||||
subject = replaceVariables(subject, variables)
|
||||
bodyHTML = replaceVariables(bodyHTML, variables)
|
||||
bodyText = replaceVariables(bodyText, variables)
|
||||
|
||||
// Send email
|
||||
if s.emailService != nil {
|
||||
err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
|
||||
template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by)
|
||||
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
|
||||
dsr.RequesterEmail, err.Error(), sentBy)
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log communication
|
||||
now := time.Now()
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO dsr_communications (request_id, direction, channel, communication_type,
|
||||
template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by)
|
||||
VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText,
|
||||
dsr.RequesterEmail, now, sentBy)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Notification helpers ---
|
||||
|
||||
func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
// Notify all DPOs
|
||||
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
rows.Scan(&userID)
|
||||
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived,
|
||||
"Neue Betroffenenanfrage",
|
||||
fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber),
|
||||
map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
dsr, _ := s.GetByID(ctx, dsrID)
|
||||
if dsr != nil {
|
||||
s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned,
|
||||
"Betroffenenanfrage zugewiesen",
|
||||
fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber),
|
||||
map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
|
||||
fmt.Sprintf("Fristwarnung: %s", requestNumber),
|
||||
fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")),
|
||||
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft})
|
||||
}
|
||||
|
||||
func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
rows.Scan(&userID)
|
||||
s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline,
|
||||
fmt.Sprintf("%s: %s", message, requestNumber),
|
||||
fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")),
|
||||
map[string]interface{}{"dsr_id": dsrID, "deadline": deadline})
|
||||
}
|
||||
}
|
||||
|
||||
// --- String utility helpers ---
|
||||
|
||||
func stringOrDefault(s *string, def string) string {
|
||||
if s != nil {
|
||||
return *s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func replaceVariables(text string, variables map[string]string) string {
|
||||
for k, v := range variables {
|
||||
text = strings.ReplaceAll(text, "{{"+k+"}}", v)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func stripHTML(html string) string {
|
||||
// Simple HTML stripping - in production use a proper library
|
||||
text := strings.ReplaceAll(html, "<br>", "\n")
|
||||
text = strings.ReplaceAll(text, "<br/>", "\n")
|
||||
text = strings.ReplaceAll(text, "<br />", "\n")
|
||||
text = strings.ReplaceAll(text, "</p>", "\n\n")
|
||||
// Remove all remaining tags
|
||||
for {
|
||||
start := strings.Index(text, "<")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(text[start:], ">")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
text = text[:start] + text[start+end+1:]
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
278
consent-service/internal/services/dsr_service_dashboard.go
Normal file
278
consent-service/internal/services/dsr_service_dashboard.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ExtendDeadline extends the deadline for a DSR
|
||||
func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error {
|
||||
// Default extension is 2 months (60 days) per Art. 12(3)
|
||||
if days <= 0 {
|
||||
days = 60
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE data_subject_requests
|
||||
SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL,
|
||||
extension_reason = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, days, reason, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extend deadline: %w", err)
|
||||
}
|
||||
|
||||
s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDashboardStats returns statistics for the admin dashboard
|
||||
func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) {
|
||||
stats := &models.DSRDashboardStats{
|
||||
ByType: make(map[string]int),
|
||||
ByStatus: make(map[string]int),
|
||||
}
|
||||
|
||||
// Total requests
|
||||
s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests)
|
||||
|
||||
// Pending requests (not completed, rejected, or cancelled)
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM data_subject_requests
|
||||
WHERE status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`).Scan(&stats.PendingRequests)
|
||||
|
||||
// Overdue requests
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) < NOW()
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`).Scan(&stats.OverdueRequests)
|
||||
|
||||
// Completed this month
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM data_subject_requests
|
||||
WHERE status = 'completed'
|
||||
AND completed_at >= DATE_TRUNC('month', NOW())
|
||||
`).Scan(&stats.CompletedThisMonth)
|
||||
|
||||
// Average processing days
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0)
|
||||
FROM data_subject_requests WHERE status = 'completed'
|
||||
`).Scan(&stats.AverageProcessingDays)
|
||||
|
||||
// Count by type
|
||||
rows, _ := s.pool.Query(ctx, `
|
||||
SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type
|
||||
`)
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var count int
|
||||
rows.Scan(&t, &count)
|
||||
stats.ByType[t] = count
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Count by status
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status
|
||||
`)
|
||||
for rows.Next() {
|
||||
var s string
|
||||
var count int
|
||||
rows.Scan(&s, &count)
|
||||
stats.ByStatus[s] = count
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Upcoming deadlines (next 7 days)
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, status, requester_email, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days'
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
ORDER BY deadline_at ASC LIMIT 10
|
||||
`)
|
||||
for rows.Next() {
|
||||
var dsr models.DataSubjectRequest
|
||||
rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt)
|
||||
stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetStatusHistory retrieves the status history for a DSR
|
||||
func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at
|
||||
FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC
|
||||
`, requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query status history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []models.DSRStatusHistory
|
||||
for rows.Next() {
|
||||
var h models.DSRStatusHistory
|
||||
var metadataJSON []byte
|
||||
err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal(metadataJSON, &h.Metadata)
|
||||
history = append(history, h)
|
||||
}
|
||||
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// InitErasureExceptionChecks initializes exception checks for an erasure request
|
||||
func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error {
|
||||
exceptions := []struct {
|
||||
Type string
|
||||
Description string
|
||||
}{
|
||||
{models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"},
|
||||
{models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"},
|
||||
{models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"},
|
||||
{models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"},
|
||||
{models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"},
|
||||
}
|
||||
|
||||
for _, exc := range exceptions {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO dsr_exception_checks (request_id, exception_type, description)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
|
||||
`, requestID, exc.Type, exc.Description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create exception check: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExceptionChecks retrieves exception checks for a DSR
|
||||
func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at
|
||||
FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at
|
||||
`, requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query exception checks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var checks []models.DSRExceptionCheck
|
||||
for rows.Next() {
|
||||
var c models.DSRExceptionCheck
|
||||
err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies,
|
||||
&c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
checks = append(checks, c)
|
||||
}
|
||||
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
// UpdateExceptionCheck updates an exception check
|
||||
func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE dsr_exception_checks
|
||||
SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW()
|
||||
WHERE id = $4
|
||||
`, applies, notes, checkedBy, checkID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ProcessDeadlines checks for approaching and overdue deadlines
|
||||
func (s *DSRService) ProcessDeadlines(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
|
||||
// Find requests with deadlines in 3 days
|
||||
threeDaysAhead := now.AddDate(0, 0, 3)
|
||||
rows, _ := s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, assigned_to, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`, now, threeDaysAhead)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var requestNumber, requestType string
|
||||
var assignedTo *uuid.UUID
|
||||
var deadline time.Time
|
||||
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
|
||||
|
||||
// Notify assigned user or all DPOs
|
||||
if assignedTo != nil {
|
||||
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3)
|
||||
} else {
|
||||
s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Find requests with deadlines in 1 day
|
||||
oneDayAhead := now.AddDate(0, 0, 1)
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, assigned_to, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`, now, oneDayAhead)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var requestNumber, requestType string
|
||||
var assignedTo *uuid.UUID
|
||||
var deadline time.Time
|
||||
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
|
||||
|
||||
if assignedTo != nil {
|
||||
s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1)
|
||||
} else {
|
||||
s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Find overdue requests
|
||||
rows, _ = s.pool.Query(ctx, `
|
||||
SELECT id, request_number, request_type, assigned_to, deadline_at
|
||||
FROM data_subject_requests
|
||||
WHERE COALESCE(extended_deadline_at, deadline_at) < $1
|
||||
AND status NOT IN ('completed', 'rejected', 'cancelled')
|
||||
`, now)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var requestNumber, requestType string
|
||||
var assignedTo *uuid.UUID
|
||||
var deadline time.Time
|
||||
rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline)
|
||||
|
||||
// Notify all DPOs for overdue
|
||||
s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline)
|
||||
|
||||
// Log to audit
|
||||
s.pool.Exec(ctx, `
|
||||
INSERT INTO consent_audit_log (action, entity_type, entity_id, details)
|
||||
VALUES ('dsr_overdue', 'dsr', $1, $2)
|
||||
`, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339)))
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package services
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
@@ -249,306 +248,3 @@ Ihr BreakPilot Team`, getDisplayName(name), appLink)
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// renderTemplate renders an email HTML template
|
||||
func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string {
|
||||
templates := map[string]string{
|
||||
"verification": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Willkommen bei BreakPilot!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.VerifyLink}}" class="button">E-Mail bestätigen</a>
|
||||
</p>
|
||||
<p>Dieser Link ist 24 Stunden gültig.</p>
|
||||
<p>Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"password_reset": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Passwort zurücksetzen</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ResetLink}}" class="button">Passwort zurücksetzen</a>
|
||||
</p>
|
||||
<div class="warning">
|
||||
<strong>Hinweis:</strong> Dieser Link ist nur 1 Stunde gültig.
|
||||
</div>
|
||||
<p>Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"new_version": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.info-box { background: #e0e7ff; border-left: 4px solid #6366f1; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Neue Version: {{.DocumentName}}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Wir haben unsere <strong>{{.DocumentName}}</strong> aktualisiert.</p>
|
||||
<div class="info-box">
|
||||
<strong>Wichtig:</strong> Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten <strong>{{.DeadlineDays}} Tage</strong>.
|
||||
</div>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ConsentLink}}" class="button">Dokument ansehen & bestätigen</a>
|
||||
</p>
|
||||
<p>Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"reminder": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #f59e0b; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
|
||||
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{.Urgency}}: Ausstehende Bestätigungen</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.</p>
|
||||
<div class="doc-list">
|
||||
<strong>Ausstehende Dokumente:</strong>
|
||||
<ul>
|
||||
{{range .Documents}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="warning">
|
||||
<strong>Sie haben noch {{.DaysLeft}} Tage Zeit.</strong> Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt.
|
||||
</div>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ConsentLink}}" class="button">Jetzt bestätigen</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"suspended": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.alert { background: #fee2e2; border-left: 4px solid #ef4444; padding: 12px; margin: 20px 0; }
|
||||
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Account vorübergehend gesperrt</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<div class="alert">
|
||||
<strong>Ihr Account wurde vorübergehend gesperrt</strong>, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben.
|
||||
</div>
|
||||
<div class="doc-list">
|
||||
<strong>Nicht bestätigte Dokumente:</strong>
|
||||
<ul>
|
||||
{{range .Documents}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<p>Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ConsentLink}}" class="button">Dokumente bestätigen & Account entsperren</a>
|
||||
</p>
|
||||
<p>Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"reactivated": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #22c55e, #16a34a); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #22c55e; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.success { background: #dcfce7; border-left: 4px solid #22c55e; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Account wieder aktiviert!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<div class="success">
|
||||
<strong>Vielen Dank!</strong> Ihr Account wurde erfolgreich wieder aktiviert.
|
||||
</div>
|
||||
<p>Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.AppLink}}" class="button">Zu BreakPilot</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"generic_notification": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{.Title}}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{.Body}}</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.BaseURL}}/app" class="button">Zu BreakPilot</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
}
|
||||
|
||||
tmplStr, ok := templates[templateName]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
tmpl, err := template.New(templateName).Parse(tmplStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// SendConsentReminderEmail sends a simplified consent reminder email
|
||||
func (s *EmailService) SendConsentReminderEmail(to, title, body string) error {
|
||||
subject := title
|
||||
|
||||
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
|
||||
"Title": title,
|
||||
"Body": body,
|
||||
"BaseURL": s.config.BaseURL,
|
||||
})
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, body)
|
||||
}
|
||||
|
||||
// SendGenericNotificationEmail sends a generic notification email
|
||||
func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error {
|
||||
subject := title
|
||||
|
||||
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
|
||||
"Title": title,
|
||||
"Body": body,
|
||||
"BaseURL": s.config.BaseURL,
|
||||
})
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, body)
|
||||
}
|
||||
|
||||
// getDisplayName returns display name or fallback
|
||||
func getDisplayName(name string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return "Nutzer"
|
||||
}
|
||||
|
||||
310
consent-service/internal/services/email_service_templates.go
Normal file
310
consent-service/internal/services/email_service_templates.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// renderTemplate renders an email HTML template
|
||||
func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string {
|
||||
templates := map[string]string{
|
||||
"verification": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Willkommen bei BreakPilot!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.VerifyLink}}" class="button">E-Mail bestätigen</a>
|
||||
</p>
|
||||
<p>Dieser Link ist 24 Stunden gültig.</p>
|
||||
<p>Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"password_reset": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Passwort zurücksetzen</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ResetLink}}" class="button">Passwort zurücksetzen</a>
|
||||
</p>
|
||||
<div class="warning">
|
||||
<strong>Hinweis:</strong> Dieser Link ist nur 1 Stunde gültig.
|
||||
</div>
|
||||
<p>Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"new_version": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.info-box { background: #e0e7ff; border-left: 4px solid #6366f1; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Neue Version: {{.DocumentName}}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Wir haben unsere <strong>{{.DocumentName}}</strong> aktualisiert.</p>
|
||||
<div class="info-box">
|
||||
<strong>Wichtig:</strong> Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten <strong>{{.DeadlineDays}} Tage</strong>.
|
||||
</div>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ConsentLink}}" class="button">Dokument ansehen & bestätigen</a>
|
||||
</p>
|
||||
<p>Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"reminder": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #f59e0b; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; }
|
||||
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{.Urgency}}: Ausstehende Bestätigungen</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<p>Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.</p>
|
||||
<div class="doc-list">
|
||||
<strong>Ausstehende Dokumente:</strong>
|
||||
<ul>
|
||||
{{range .Documents}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="warning">
|
||||
<strong>Sie haben noch {{.DaysLeft}} Tage Zeit.</strong> Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt.
|
||||
</div>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ConsentLink}}" class="button">Jetzt bestätigen</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"suspended": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.alert { background: #fee2e2; border-left: 4px solid #ef4444; padding: 12px; margin: 20px 0; }
|
||||
.doc-list { background: white; padding: 15px; border-radius: 8px; margin: 15px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Account vorübergehend gesperrt</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<div class="alert">
|
||||
<strong>Ihr Account wurde vorübergehend gesperrt</strong>, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben.
|
||||
</div>
|
||||
<div class="doc-list">
|
||||
<strong>Nicht bestätigte Dokumente:</strong>
|
||||
<ul>
|
||||
{{range .Documents}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<p>Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.ConsentLink}}" class="button">Dokumente bestätigen & Account entsperren</a>
|
||||
</p>
|
||||
<p>Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"reactivated": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #22c55e, #16a34a); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #22c55e; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.success { background: #dcfce7; border-left: 4px solid #22c55e; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Account wieder aktiviert!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{.Name}},</p>
|
||||
<div class="success">
|
||||
<strong>Vielen Dank!</strong> Ihr Account wurde erfolgreich wieder aktiviert.
|
||||
</div>
|
||||
<p>Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.AppLink}}" class="button">Zu BreakPilot</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"generic_notification": `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center; }
|
||||
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||
.button { display: inline-block; background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: 600; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{.Title}}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{.Body}}</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.BaseURL}}/app" class="button">Zu BreakPilot</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 BreakPilot. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
}
|
||||
|
||||
tmplStr, ok := templates[templateName]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
tmpl, err := template.New(templateName).Parse(tmplStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// SendConsentReminderEmail sends a simplified consent reminder email
|
||||
func (s *EmailService) SendConsentReminderEmail(to, title, body string) error {
|
||||
subject := title
|
||||
|
||||
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
|
||||
"Title": title,
|
||||
"Body": body,
|
||||
"BaseURL": s.config.BaseURL,
|
||||
})
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, body)
|
||||
}
|
||||
|
||||
// SendGenericNotificationEmail sends a generic notification email
|
||||
func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error {
|
||||
subject := title
|
||||
|
||||
htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{
|
||||
"Title": title,
|
||||
"Body": body,
|
||||
"BaseURL": s.config.BaseURL,
|
||||
})
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, body)
|
||||
}
|
||||
|
||||
// getDisplayName returns display name or fallback
|
||||
func getDisplayName(name string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return "Nutzer"
|
||||
}
|
||||
174
consent-service/internal/services/email_template_approval.go
Normal file
174
consent-service/internal/services/email_template_approval.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SubmitForReview submits a version for review
|
||||
func (s *EmailTemplateService) SubmitForReview(ctx context.Context, versionID, submitterID uuid.UUID, comment *string) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Update status
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE email_template_versions SET status = 'review', updated_at = $1 WHERE id = $2
|
||||
`, time.Now(), versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update status: %w", err)
|
||||
}
|
||||
|
||||
// Create approval record
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, uuid.New(), versionID, submitterID, "submitted_for_review", comment, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create approval record: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ApproveVersion approves a version (DSB)
|
||||
func (s *EmailTemplateService) ApproveVersion(ctx context.Context, versionID, approverID uuid.UUID, comment *string, scheduledPublishAt *time.Time) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
now := time.Now()
|
||||
status := "approved"
|
||||
if scheduledPublishAt != nil {
|
||||
status = "scheduled"
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE email_template_versions
|
||||
SET status = $1, approved_by = $2, approved_at = $3, scheduled_publish_at = $4, updated_at = $5
|
||||
WHERE id = $6
|
||||
`, status, approverID, now, scheduledPublishAt, now, versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to approve version: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, uuid.New(), versionID, approverID, "approved", comment, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create approval record: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// PublishVersion publishes an approved version
|
||||
func (s *EmailTemplateService) PublishVersion(ctx context.Context, versionID, publisherID uuid.UUID) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Get version info to find template and language
|
||||
var templateID uuid.UUID
|
||||
var language string
|
||||
err = tx.QueryRow(ctx, `SELECT template_id, language FROM email_template_versions WHERE id = $1`, versionID).Scan(&templateID, &language)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get version info: %w", err)
|
||||
}
|
||||
|
||||
// Archive old published versions for same template and language
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE email_template_versions
|
||||
SET status = 'archived', updated_at = $1
|
||||
WHERE template_id = $2 AND language = $3 AND status = 'published'
|
||||
`, time.Now(), templateID, language)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to archive old versions: %w", err)
|
||||
}
|
||||
|
||||
// Publish the new version
|
||||
now := time.Now()
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE email_template_versions
|
||||
SET status = 'published', published_at = $1, updated_at = $2
|
||||
WHERE id = $3
|
||||
`, now, now, versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish version: %w", err)
|
||||
}
|
||||
|
||||
// Create approval record
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO email_template_approvals (id, version_id, approver_id, action, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, uuid.New(), versionID, publisherID, "published", now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create approval record: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// RejectVersion rejects a version
|
||||
func (s *EmailTemplateService) RejectVersion(ctx context.Context, versionID, rejectorID uuid.UUID, comment string) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
now := time.Now()
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE email_template_versions SET status = 'draft', updated_at = $1 WHERE id = $2
|
||||
`, now, versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reject version: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, uuid.New(), versionID, rejectorID, "rejected", &comment, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create approval record: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// GetApprovals returns approval history for a version
|
||||
func (s *EmailTemplateService) GetApprovals(ctx context.Context, versionID uuid.UUID) ([]models.EmailTemplateApproval, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, version_id, approver_id, action, comment, created_at
|
||||
FROM email_template_approvals
|
||||
WHERE version_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, versionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get approvals: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var approvals []models.EmailTemplateApproval
|
||||
for rows.Next() {
|
||||
var a models.EmailTemplateApproval
|
||||
err := rows.Scan(&a.ID, &a.VersionID, &a.ApproverID, &a.Action, &a.Comment, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan approval: %w", err)
|
||||
}
|
||||
approvals = append(approvals, a)
|
||||
}
|
||||
|
||||
return approvals, nil
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package services
|
||||
|
||||
// Default German email templates for authentication and security events.
|
||||
// Templates: Welcome, Email Verification, Password Reset, Password Changed,
|
||||
// 2FA Enabled, 2FA Disabled, New Device Login, Suspicious Activity,
|
||||
// Account Locked, Account Unlocked.
|
||||
|
||||
func (s *EmailTemplateService) getWelcomeTemplateDE() (string, string, string) {
|
||||
subject := "Willkommen bei BreakPilot!"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Willkommen bei BreakPilot!</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.</p>
|
||||
<p>Sie können sich jetzt mit Ihrer E-Mail-Adresse <strong>{{user_email}}</strong> anmelden:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{login_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt anmelden</a>
|
||||
</p>
|
||||
<p>Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Willkommen bei BreakPilot!
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.
|
||||
|
||||
Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden:
|
||||
{{login_url}}
|
||||
|
||||
Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getEmailVerificationTemplateDE() (string, string, string) {
|
||||
subject := "Bitte bestätigen Sie Ihre E-Mail-Adresse"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">E-Mail-Adresse bestätigen</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{verification_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">E-Mail bestätigen</a>
|
||||
</p>
|
||||
<p>Alternativ können Sie auch diesen Bestätigungscode eingeben: <strong>{{verification_code}}</strong></p>
|
||||
<p><strong>Hinweis:</strong> Dieser Link ist nur {{expires_in}} gültig.</p>
|
||||
<p>Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `E-Mail-Adresse bestätigen
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:
|
||||
{{verification_url}}
|
||||
|
||||
Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}}
|
||||
|
||||
Hinweis: Dieser Link ist nur {{expires_in}} gültig.
|
||||
|
||||
Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getPasswordResetTemplateDE() (string, string, string) {
|
||||
subject := "Passwort zurücksetzen"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Passwort zurücksetzen</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{reset_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Neues Passwort festlegen</a>
|
||||
</p>
|
||||
<p>Alternativ können Sie auch diesen Code verwenden: <strong>{{reset_code}}</strong></p>
|
||||
<p><strong>Hinweis:</strong> Dieser Link ist nur {{expires_in}} gültig.</p>
|
||||
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Sicherheitshinweis:</strong> Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Passwort zurücksetzen
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:
|
||||
{{reset_url}}
|
||||
|
||||
Alternativ können Sie auch diesen Code verwenden: {{reset_code}}
|
||||
|
||||
Hinweis: Dieser Link ist nur {{expires_in}} gültig.
|
||||
|
||||
Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getPasswordChangedTemplateDE() (string, string, string) {
|
||||
subject := "Ihr Passwort wurde geändert"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Passwort geändert</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.</p>
|
||||
<p><strong>Details:</strong></p>
|
||||
<ul>
|
||||
<li>IP-Adresse: {{ip_address}}</li>
|
||||
<li>Gerät: {{device_info}}</li>
|
||||
</ul>
|
||||
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Nicht Sie?</strong> Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Passwort geändert
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.
|
||||
|
||||
Details:
|
||||
- IP-Adresse: {{ip_address}}
|
||||
- Gerät: {{device_info}}
|
||||
|
||||
Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) get2FAEnabledTemplateDE() (string, string, string) {
|
||||
subject := "Zwei-Faktor-Authentifizierung aktiviert"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #059669;">2FA aktiviert</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.</p>
|
||||
<p><strong>Gerät:</strong> {{device_info}}</p>
|
||||
<p style="background-color: #d1fae5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Sicherheitstipp:</strong> Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren.
|
||||
</p>
|
||||
<p>Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `2FA aktiviert
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.
|
||||
|
||||
Gerät: {{device_info}}
|
||||
|
||||
Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren.
|
||||
|
||||
Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) get2FADisabledTemplateDE() (string, string, string) {
|
||||
subject := "Zwei-Faktor-Authentifizierung deaktiviert"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #dc2626;">2FA deaktiviert</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.</p>
|
||||
<p><strong>IP-Adresse:</strong> {{ip_address}}</p>
|
||||
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Warnung:</strong> Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren.
|
||||
</p>
|
||||
<p>Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `2FA deaktiviert
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.
|
||||
|
||||
IP-Adresse: {{ip_address}}
|
||||
|
||||
Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren.
|
||||
|
||||
Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getNewDeviceLoginTemplateDE() (string, string, string) {
|
||||
subject := "Neuer Login auf Ihrem Konto"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #f59e0b;">Neuer Login erkannt</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:</p>
|
||||
<ul>
|
||||
<li><strong>Zeitpunkt:</strong> {{login_time}}</li>
|
||||
<li><strong>IP-Adresse:</strong> {{ip_address}}</li>
|
||||
<li><strong>Gerät:</strong> {{device_info}}</li>
|
||||
<li><strong>Standort:</strong> {{location}}</li>
|
||||
</ul>
|
||||
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Nicht Sie?</strong> Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Neuer Login erkannt
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:
|
||||
|
||||
- Zeitpunkt: {{login_time}}
|
||||
- IP-Adresse: {{ip_address}}
|
||||
- Gerät: {{device_info}}
|
||||
- Standort: {{location}}
|
||||
|
||||
Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getSuspiciousActivityTemplateDE() (string, string, string) {
|
||||
subject := "Verdächtige Aktivität auf Ihrem Konto"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #dc2626;">Verdächtige Aktivität erkannt</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:</p>
|
||||
<ul>
|
||||
<li><strong>Art:</strong> {{activity_type}}</li>
|
||||
<li><strong>Zeitpunkt:</strong> {{activity_time}}</li>
|
||||
<li><strong>IP-Adresse:</strong> {{ip_address}}</li>
|
||||
</ul>
|
||||
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Wichtig:</strong> Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Verdächtige Aktivität erkannt
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:
|
||||
|
||||
- Art: {{activity_type}}
|
||||
- Zeitpunkt: {{activity_time}}
|
||||
- IP-Adresse: {{ip_address}}
|
||||
|
||||
Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getAccountLockedTemplateDE() (string, string, string) {
|
||||
subject := "Ihr Konto wurde gesperrt"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #dc2626;">Konto gesperrt</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:</p>
|
||||
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
{{reason}}
|
||||
</p>
|
||||
<p>Ihr Konto wird automatisch entsperrt am: <strong>{{unlock_time}}</strong></p>
|
||||
<p>Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Konto gesperrt
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:
|
||||
|
||||
{{reason}}
|
||||
|
||||
Ihr Konto wird automatisch entsperrt am: {{unlock_time}}
|
||||
|
||||
Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getAccountUnlockedTemplateDE() (string, string, string) {
|
||||
subject := "Ihr Konto wurde entsperrt"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #059669;">Konto entsperrt</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{login_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt anmelden</a>
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Konto entsperrt
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.
|
||||
|
||||
Sie können sich jetzt wieder anmelden: {{login_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package services
|
||||
|
||||
// Default German email templates for account lifecycle and consent events.
|
||||
// Templates: Deletion Requested, Deletion Confirmed, Data Export Ready,
|
||||
// Email Changed, New Version Published, Consent Reminder,
|
||||
// Consent Deadline Warning, Account Suspended.
|
||||
|
||||
func (s *EmailTemplateService) getDeletionRequestedTemplateDE() (string, string, string) {
|
||||
subject := "Bestätigung: Kontolöschung angefordert"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #dc2626;">Kontolöschung angefordert</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.</p>
|
||||
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Wichtig:</strong> Ihr Konto und alle zugehörigen Daten werden endgültig am <strong>{{deletion_date}}</strong> gelöscht.
|
||||
</p>
|
||||
<p><strong>Folgende Daten werden gelöscht:</strong></p>
|
||||
<p>{{data_info}}</p>
|
||||
<p>Sie können die Löschung bis zum genannten Datum abbrechen:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{cancel_url}}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Löschung abbrechen</a>
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Kontolöschung angefordert
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.
|
||||
|
||||
Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht.
|
||||
|
||||
Folgende Daten werden gelöscht:
|
||||
{{data_info}}
|
||||
|
||||
Sie können die Löschung bis zum genannten Datum abbrechen: {{cancel_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getDeletionConfirmedTemplateDE() (string, string, string) {
|
||||
subject := "Ihr Konto wurde gelöscht"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #6b7280;">Konto gelöscht</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.</p>
|
||||
<p>Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{feedback_url}}" style="background-color: #6b7280; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Feedback geben</a>
|
||||
</p>
|
||||
<p>Vielen Dank für Ihre Zeit bei BreakPilot.</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Konto gelöscht
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.
|
||||
|
||||
Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten: {{feedback_url}}
|
||||
|
||||
Vielen Dank für Ihre Zeit bei BreakPilot.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getDataExportReadyTemplateDE() (string, string, string) {
|
||||
subject := "Ihr Datenexport ist bereit"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Datenexport bereit</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit.</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{download_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Daten herunterladen ({{file_size}})</a>
|
||||
</p>
|
||||
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Hinweis:</strong> Der Download-Link ist nur {{expires_in}} gültig.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Datenexport bereit
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit:
|
||||
{{download_url}}
|
||||
|
||||
Dateigröße: {{file_size}}
|
||||
|
||||
Hinweis: Der Download-Link ist nur {{expires_in}} gültig.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getEmailChangedTemplateDE() (string, string, string) {
|
||||
subject := "Ihre E-Mail-Adresse wurde geändert"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">E-Mail-Adresse geändert</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.</p>
|
||||
<ul>
|
||||
<li><strong>Alte Adresse:</strong> {{old_email}}</li>
|
||||
<li><strong>Neue Adresse:</strong> {{new_email}}</li>
|
||||
</ul>
|
||||
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Nicht Sie?</strong> Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `E-Mail-Adresse geändert
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.
|
||||
|
||||
- Alte Adresse: {{old_email}}
|
||||
- Neue Adresse: {{new_email}}
|
||||
|
||||
Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getNewVersionPublishedTemplateDE() (string, string, string) {
|
||||
subject := "Neue Version: {{document_name}} - Ihre Zustimmung ist erforderlich"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Neue Dokumentversion</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Eine neue Version unserer <strong>{{document_name}}</strong> ({{document_type}}) wurde veröffentlicht.</p>
|
||||
<p><strong>Version:</strong> {{version}}</p>
|
||||
<p style="background-color: #dbeafe; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum <strong>{{deadline}}</strong>.
|
||||
</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{consent_url}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt prüfen und zustimmen</a>
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Neue Dokumentversion
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht.
|
||||
|
||||
Version: {{version}}
|
||||
|
||||
Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}.
|
||||
|
||||
Jetzt prüfen und zustimmen: {{consent_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getConsentReminderTemplateDE() (string, string, string) {
|
||||
subject := "Erinnerung: Zustimmung zu {{document_name}} erforderlich"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #f59e0b;">Erinnerung: Zustimmung erforderlich</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur <strong>{{document_name}}</strong> noch aussteht.</p>
|
||||
<p style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Noch {{days_left}} Tage</strong> bis zur Frist am {{deadline}}.
|
||||
</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{consent_url}}" style="background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Jetzt zustimmen</a>
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Erinnerung: Zustimmung erforderlich
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht.
|
||||
|
||||
Noch {{days_left}} Tage bis zur Frist am {{deadline}}.
|
||||
|
||||
Jetzt zustimmen: {{consent_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getConsentDeadlineWarningTemplateDE() (string, string, string) {
|
||||
subject := "DRINGEND: Zustimmung zu {{document_name}} läuft bald ab"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #dc2626;">Dringende Erinnerung</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihre Frist zur Zustimmung zur <strong>{{document_name}}</strong> läuft in <strong>{{hours_left}}</strong> ab!</p>
|
||||
<p style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Wichtig:</strong> {{consequences}}
|
||||
</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{consent_url}}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Sofort zustimmen</a>
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Dringende Erinnerung
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab!
|
||||
|
||||
Wichtig: {{consequences}}
|
||||
|
||||
Sofort zustimmen: {{consent_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
|
||||
func (s *EmailTemplateService) getAccountSuspendedTemplateDE() (string, string, string) {
|
||||
subject := "Ihr Konto wurde suspendiert"
|
||||
bodyHTML := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #dc2626;">Konto suspendiert</h1>
|
||||
<p>Hallo {{user_name}},</p>
|
||||
<p>Ihr Konto wurde am {{suspended_at}} suspendiert.</p>
|
||||
<p><strong>Grund:</strong> {{reason}}</p>
|
||||
<p><strong>Fehlende Zustimmungen:</strong></p>
|
||||
<p>{{documents}}</p>
|
||||
<p>Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{consent_url}}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px;">Konto reaktivieren</a>
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr BreakPilot-Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
bodyText := `Konto suspendiert
|
||||
|
||||
Hallo {{user_name}},
|
||||
|
||||
Ihr Konto wurde am {{suspended_at}} suspendiert.
|
||||
|
||||
Grund: {{reason}}
|
||||
|
||||
Fehlende Zustimmungen:
|
||||
{{documents}}
|
||||
|
||||
Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu: {{consent_url}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr BreakPilot-Team`
|
||||
return subject, bodyHTML, bodyText
|
||||
}
|
||||
273
consent-service/internal/services/email_template_render.go
Normal file
273
consent-service/internal/services/email_template_render.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RenderTemplate renders a template with variables
|
||||
func (s *EmailTemplateService) RenderTemplate(version *models.EmailTemplateVersion, variables map[string]string) (*models.EmailPreviewResponse, error) {
|
||||
subject := version.Subject
|
||||
bodyHTML := version.BodyHTML
|
||||
bodyText := version.BodyText
|
||||
|
||||
// Replace variables in format {{variable_name}}
|
||||
re := regexp.MustCompile(`\{\{(\w+)\}\}`)
|
||||
|
||||
replaceFunc := func(content string) string {
|
||||
return re.ReplaceAllStringFunc(content, func(match string) string {
|
||||
varName := strings.Trim(match, "{}")
|
||||
if val, ok := variables[varName]; ok {
|
||||
return val
|
||||
}
|
||||
return match // Keep placeholder if variable not provided
|
||||
})
|
||||
}
|
||||
|
||||
return &models.EmailPreviewResponse{
|
||||
Subject: replaceFunc(subject),
|
||||
BodyHTML: replaceFunc(bodyHTML),
|
||||
BodyText: replaceFunc(bodyText),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LogEmailSend logs a sent email
|
||||
func (s *EmailTemplateService) LogEmailSend(ctx context.Context, log *models.EmailSendLog) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO email_send_logs
|
||||
(id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, log.ID, log.UserID, log.VersionID, log.Recipient, log.Subject, log.Status,
|
||||
log.ErrorMsg, log.Variables, log.SentAt, log.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to log email send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEmailStats returns email statistics
|
||||
func (s *EmailTemplateService) GetEmailStats(ctx context.Context) (*models.EmailStats, error) {
|
||||
var stats models.EmailStats
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*) as total_sent,
|
||||
COUNT(*) FILTER (WHERE status = 'delivered') as delivered,
|
||||
COUNT(*) FILTER (WHERE status = 'bounced') as bounced,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') as recent_sent
|
||||
FROM email_send_logs
|
||||
`).Scan(&stats.TotalSent, &stats.Delivered, &stats.Bounced, &stats.Failed, &stats.RecentSent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get email stats: %w", err)
|
||||
}
|
||||
|
||||
if stats.TotalSent > 0 {
|
||||
stats.DeliveryRate = float64(stats.Delivered) / float64(stats.TotalSent) * 100
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// GetDefaultTemplateContent returns default content for a template type
|
||||
func (s *EmailTemplateService) GetDefaultTemplateContent(templateType, language string) (subject, bodyHTML, bodyText string) {
|
||||
// Default templates in German
|
||||
if language == "de" {
|
||||
switch templateType {
|
||||
case models.EmailTypeWelcome:
|
||||
return s.getWelcomeTemplateDE()
|
||||
case models.EmailTypeEmailVerification:
|
||||
return s.getEmailVerificationTemplateDE()
|
||||
case models.EmailTypePasswordReset:
|
||||
return s.getPasswordResetTemplateDE()
|
||||
case models.EmailTypePasswordChanged:
|
||||
return s.getPasswordChangedTemplateDE()
|
||||
case models.EmailType2FAEnabled:
|
||||
return s.get2FAEnabledTemplateDE()
|
||||
case models.EmailType2FADisabled:
|
||||
return s.get2FADisabledTemplateDE()
|
||||
case models.EmailTypeNewDeviceLogin:
|
||||
return s.getNewDeviceLoginTemplateDE()
|
||||
case models.EmailTypeSuspiciousActivity:
|
||||
return s.getSuspiciousActivityTemplateDE()
|
||||
case models.EmailTypeAccountLocked:
|
||||
return s.getAccountLockedTemplateDE()
|
||||
case models.EmailTypeAccountUnlocked:
|
||||
return s.getAccountUnlockedTemplateDE()
|
||||
case models.EmailTypeDeletionRequested:
|
||||
return s.getDeletionRequestedTemplateDE()
|
||||
case models.EmailTypeDeletionConfirmed:
|
||||
return s.getDeletionConfirmedTemplateDE()
|
||||
case models.EmailTypeDataExportReady:
|
||||
return s.getDataExportReadyTemplateDE()
|
||||
case models.EmailTypeEmailChanged:
|
||||
return s.getEmailChangedTemplateDE()
|
||||
case models.EmailTypeNewVersionPublished:
|
||||
return s.getNewVersionPublishedTemplateDE()
|
||||
case models.EmailTypeConsentReminder:
|
||||
return s.getConsentReminderTemplateDE()
|
||||
case models.EmailTypeConsentDeadlineWarning:
|
||||
return s.getConsentDeadlineWarningTemplateDE()
|
||||
case models.EmailTypeAccountSuspended:
|
||||
return s.getAccountSuspendedTemplateDE()
|
||||
}
|
||||
}
|
||||
|
||||
// Default English fallback
|
||||
return "No template", "<p>No template available</p>", "No template available"
|
||||
}
|
||||
|
||||
// InitDefaultTemplates creates default email templates if they don't exist
|
||||
func (s *EmailTemplateService) InitDefaultTemplates(ctx context.Context) error {
|
||||
templateTypes := []struct {
|
||||
Type string
|
||||
Name string
|
||||
Description string
|
||||
SortOrder int
|
||||
}{
|
||||
{models.EmailTypeWelcome, "Willkommens-E-Mail", "Wird nach erfolgreicher Registrierung gesendet", 1},
|
||||
{models.EmailTypeEmailVerification, "E-Mail-Verifizierung", "Enthält Link zur E-Mail-Bestätigung", 2},
|
||||
{models.EmailTypePasswordReset, "Passwort zurücksetzen", "Enthält Link zum Passwort-Reset", 3},
|
||||
{models.EmailTypePasswordChanged, "Passwort geändert", "Bestätigung der Passwortänderung", 4},
|
||||
{models.EmailType2FAEnabled, "2FA aktiviert", "Bestätigung der 2FA-Aktivierung", 5},
|
||||
{models.EmailType2FADisabled, "2FA deaktiviert", "Warnung über 2FA-Deaktivierung", 6},
|
||||
{models.EmailTypeNewDeviceLogin, "Neuer Login", "Benachrichtigung über Login von neuem Gerät", 7},
|
||||
{models.EmailTypeSuspiciousActivity, "Verdächtige Aktivität", "Warnung über verdächtige Kontoaktivität", 8},
|
||||
{models.EmailTypeAccountLocked, "Konto gesperrt", "Benachrichtigung über Kontosperrung", 9},
|
||||
{models.EmailTypeAccountUnlocked, "Konto entsperrt", "Bestätigung der Kontoentsperrung", 10},
|
||||
{models.EmailTypeDeletionRequested, "Löschung angefordert", "Bestätigung der Löschanfrage", 11},
|
||||
{models.EmailTypeDeletionConfirmed, "Löschung bestätigt", "Bestätigung der Kontolöschung", 12},
|
||||
{models.EmailTypeDataExportReady, "Datenexport bereit", "Benachrichtigung über fertigen Datenexport", 13},
|
||||
{models.EmailTypeEmailChanged, "E-Mail geändert", "Bestätigung der E-Mail-Änderung", 14},
|
||||
{models.EmailTypeNewVersionPublished, "Neue Version veröffentlicht", "Benachrichtigung über neue Dokumentversion", 15},
|
||||
{models.EmailTypeConsentReminder, "Zustimmungs-Erinnerung", "Erinnerung an ausstehende Zustimmung", 16},
|
||||
{models.EmailTypeConsentDeadlineWarning, "Frist-Warnung", "Dringende Warnung vor ablaufender Frist", 17},
|
||||
{models.EmailTypeAccountSuspended, "Konto suspendiert", "Benachrichtigung über Kontosuspendierung", 18},
|
||||
}
|
||||
|
||||
for _, tt := range templateTypes {
|
||||
// Check if template exists
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM email_templates WHERE type = $1)`, tt.Type).Scan(&exists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check template existence: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
desc := tt.Description
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO email_templates (id, type, name, description, is_active, sort_order, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, uuid.New(), tt.Type, tt.Name, &desc, true, tt.SortOrder, time.Now(), time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create template %s: %w", tt.Type, err)
|
||||
}
|
||||
|
||||
// Create default German version
|
||||
template, err := s.GetTemplateByType(ctx, tt.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get template %s: %w", tt.Type, err)
|
||||
}
|
||||
|
||||
subject, bodyHTML, bodyText := s.GetDefaultTemplateContent(tt.Type, "de")
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO email_template_versions
|
||||
(id, template_id, version, language, subject, body_html, body_text, status, published_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, uuid.New(), template.ID, "1.0.0", "de", subject, bodyHTML, bodyText, "published", time.Now(), time.Now(), time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create template version %s: %w", tt.Type, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSendLogs returns email send logs with optional filtering
|
||||
func (s *EmailTemplateService) GetSendLogs(ctx context.Context, limit, offset int) ([]models.EmailSendLog, int, error) {
|
||||
var total int
|
||||
err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM email_send_logs`).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count send logs: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, delivered_at, created_at
|
||||
FROM email_send_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get send logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []models.EmailSendLog
|
||||
for rows.Next() {
|
||||
var log models.EmailSendLog
|
||||
err := rows.Scan(&log.ID, &log.UserID, &log.VersionID, &log.Recipient, &log.Subject,
|
||||
&log.Status, &log.ErrorMsg, &log.Variables, &log.SentAt, &log.DeliveredAt, &log.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan send log: %w", err)
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// SendEmail sends an email using the specified template (stub - actual sending would use SMTP)
|
||||
func (s *EmailTemplateService) SendEmail(ctx context.Context, templateType, language, recipient string, variables map[string]string, userID *uuid.UUID) error {
|
||||
// Get published version
|
||||
version, err := s.GetPublishedVersion(ctx, templateType, language)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get published version: %w", err)
|
||||
}
|
||||
|
||||
// Render template
|
||||
rendered, err := s.RenderTemplate(version, variables)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render template: %w", err)
|
||||
}
|
||||
|
||||
// Log the send attempt
|
||||
variablesJSON, _ := json.Marshal(variables)
|
||||
now := time.Now()
|
||||
sendLog := &models.EmailSendLog{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
VersionID: version.ID,
|
||||
Recipient: recipient,
|
||||
Subject: rendered.Subject,
|
||||
Status: "queued",
|
||||
Variables: ptr(string(variablesJSON)),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.LogEmailSend(ctx, sendLog); err != nil {
|
||||
return fmt.Errorf("failed to log email send: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Actual email sending via SMTP would go here
|
||||
// For now, we just log it as "sent"
|
||||
_, err = s.db.Exec(ctx, `
|
||||
UPDATE email_send_logs SET status = 'sent', sent_at = $1 WHERE id = $2
|
||||
`, now, sendLog.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update send log status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
300
consent-service/internal/services/email_template_settings.go
Normal file
300
consent-service/internal/services/email_template_settings.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetAllTemplateTypes returns all available email template types with their variables
|
||||
func (s *EmailTemplateService) GetAllTemplateTypes() []models.EmailTemplateVariables {
|
||||
return []models.EmailTemplateVariables{
|
||||
{
|
||||
TemplateType: models.EmailTypeWelcome,
|
||||
Variables: []string{"user_name", "user_email", "login_url", "support_email"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"user_email": "E-Mail-Adresse des Benutzers",
|
||||
"login_url": "URL zur Login-Seite",
|
||||
"support_email": "Support E-Mail-Adresse",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeEmailVerification,
|
||||
Variables: []string{"user_name", "verification_url", "verification_code", "expires_in"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"verification_url": "URL zur E-Mail-Verifizierung",
|
||||
"verification_code": "Verifizierungscode",
|
||||
"expires_in": "Gültigkeit des Links (z.B. '24 Stunden')",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypePasswordReset,
|
||||
Variables: []string{"user_name", "reset_url", "reset_code", "expires_in", "ip_address"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"reset_url": "URL zum Passwort-Reset",
|
||||
"reset_code": "Reset-Code",
|
||||
"expires_in": "Gültigkeit des Links",
|
||||
"ip_address": "IP-Adresse der Anfrage",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypePasswordChanged,
|
||||
Variables: []string{"user_name", "changed_at", "ip_address", "device_info", "support_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"changed_at": "Zeitpunkt der Änderung",
|
||||
"ip_address": "IP-Adresse",
|
||||
"device_info": "Geräte-Informationen",
|
||||
"support_url": "URL zum Support",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailType2FAEnabled,
|
||||
Variables: []string{"user_name", "enabled_at", "device_info", "security_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"enabled_at": "Zeitpunkt der Aktivierung",
|
||||
"device_info": "Geräte-Informationen",
|
||||
"security_url": "URL zu Sicherheitseinstellungen",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailType2FADisabled,
|
||||
Variables: []string{"user_name", "disabled_at", "ip_address", "security_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"disabled_at": "Zeitpunkt der Deaktivierung",
|
||||
"ip_address": "IP-Adresse",
|
||||
"security_url": "URL zu Sicherheitseinstellungen",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeNewDeviceLogin,
|
||||
Variables: []string{"user_name", "login_time", "ip_address", "device_info", "location", "security_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"login_time": "Zeitpunkt des Logins",
|
||||
"ip_address": "IP-Adresse",
|
||||
"device_info": "Geräte-Informationen",
|
||||
"location": "Ungefährer Standort",
|
||||
"security_url": "URL zu Sicherheitseinstellungen",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeSuspiciousActivity,
|
||||
Variables: []string{"user_name", "activity_type", "activity_time", "ip_address", "security_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"activity_type": "Art der Aktivität",
|
||||
"activity_time": "Zeitpunkt",
|
||||
"ip_address": "IP-Adresse",
|
||||
"security_url": "URL zu Sicherheitseinstellungen",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeAccountLocked,
|
||||
Variables: []string{"user_name", "locked_at", "reason", "unlock_time", "support_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"locked_at": "Zeitpunkt der Sperrung",
|
||||
"reason": "Grund der Sperrung",
|
||||
"unlock_time": "Zeitpunkt der automatischen Entsperrung",
|
||||
"support_url": "URL zum Support",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeAccountUnlocked,
|
||||
Variables: []string{"user_name", "unlocked_at", "login_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"unlocked_at": "Zeitpunkt der Entsperrung",
|
||||
"login_url": "URL zur Login-Seite",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeDeletionRequested,
|
||||
Variables: []string{"user_name", "requested_at", "deletion_date", "cancel_url", "data_info"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"requested_at": "Zeitpunkt der Anfrage",
|
||||
"deletion_date": "Datum der endgültigen Löschung",
|
||||
"cancel_url": "URL zum Abbrechen",
|
||||
"data_info": "Info über zu löschende Daten",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeDeletionConfirmed,
|
||||
Variables: []string{"user_name", "deleted_at", "feedback_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"deleted_at": "Zeitpunkt der Löschung",
|
||||
"feedback_url": "URL für Feedback",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeDataExportReady,
|
||||
Variables: []string{"user_name", "download_url", "expires_in", "file_size"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"download_url": "URL zum Download",
|
||||
"expires_in": "Gültigkeit des Download-Links",
|
||||
"file_size": "Dateigröße",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeEmailChanged,
|
||||
Variables: []string{"user_name", "old_email", "new_email", "changed_at", "support_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"old_email": "Alte E-Mail-Adresse",
|
||||
"new_email": "Neue E-Mail-Adresse",
|
||||
"changed_at": "Zeitpunkt der Änderung",
|
||||
"support_url": "URL zum Support",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeEmailChangeVerify,
|
||||
Variables: []string{"user_name", "new_email", "verification_url", "expires_in"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"new_email": "Neue E-Mail-Adresse",
|
||||
"verification_url": "URL zur Verifizierung",
|
||||
"expires_in": "Gültigkeit des Links",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeNewVersionPublished,
|
||||
Variables: []string{"user_name", "document_name", "document_type", "version", "consent_url", "deadline"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"document_name": "Name des Dokuments",
|
||||
"document_type": "Typ des Dokuments",
|
||||
"version": "Versionsnummer",
|
||||
"consent_url": "URL zur Zustimmung",
|
||||
"deadline": "Frist für die Zustimmung",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeConsentReminder,
|
||||
Variables: []string{"user_name", "document_name", "days_left", "consent_url", "deadline"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"document_name": "Name des Dokuments",
|
||||
"days_left": "Verbleibende Tage",
|
||||
"consent_url": "URL zur Zustimmung",
|
||||
"deadline": "Frist für die Zustimmung",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeConsentDeadlineWarning,
|
||||
Variables: []string{"user_name", "document_name", "hours_left", "consent_url", "consequences"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"document_name": "Name des Dokuments",
|
||||
"hours_left": "Verbleibende Stunden",
|
||||
"consent_url": "URL zur Zustimmung",
|
||||
"consequences": "Konsequenzen bei Nicht-Zustimmung",
|
||||
},
|
||||
},
|
||||
{
|
||||
TemplateType: models.EmailTypeAccountSuspended,
|
||||
Variables: []string{"user_name", "suspended_at", "reason", "documents", "consent_url"},
|
||||
Descriptions: map[string]string{
|
||||
"user_name": "Name des Benutzers",
|
||||
"suspended_at": "Zeitpunkt der Suspendierung",
|
||||
"reason": "Grund der Suspendierung",
|
||||
"documents": "Liste der fehlenden Zustimmungen",
|
||||
"consent_url": "URL zur Zustimmung",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings returns global email settings
|
||||
func (s *EmailTemplateService) GetSettings(ctx context.Context) (*models.EmailTemplateSettings, error) {
|
||||
var settings models.EmailTemplateSettings
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, logo_url, logo_base64, company_name, sender_name, sender_email,
|
||||
reply_to_email, footer_html, footer_text, primary_color, secondary_color,
|
||||
updated_at, updated_by
|
||||
FROM email_template_settings
|
||||
LIMIT 1
|
||||
`).Scan(&settings.ID, &settings.LogoURL, &settings.LogoBase64, &settings.CompanyName,
|
||||
&settings.SenderName, &settings.SenderEmail, &settings.ReplyToEmail, &settings.FooterHTML,
|
||||
&settings.FooterText, &settings.PrimaryColor, &settings.SecondaryColor,
|
||||
&settings.UpdatedAt, &settings.UpdatedBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get email settings: %w", err)
|
||||
}
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings updates global email settings
|
||||
func (s *EmailTemplateService) UpdateSettings(ctx context.Context, req *models.UpdateEmailTemplateSettingsRequest, updatedBy uuid.UUID) error {
|
||||
query := "UPDATE email_template_settings SET updated_at = $1, updated_by = $2"
|
||||
args := []interface{}{time.Now(), updatedBy}
|
||||
argIdx := 3
|
||||
|
||||
if req.LogoURL != nil {
|
||||
query += fmt.Sprintf(", logo_url = $%d", argIdx)
|
||||
args = append(args, *req.LogoURL)
|
||||
argIdx++
|
||||
}
|
||||
if req.LogoBase64 != nil {
|
||||
query += fmt.Sprintf(", logo_base64 = $%d", argIdx)
|
||||
args = append(args, *req.LogoBase64)
|
||||
argIdx++
|
||||
}
|
||||
if req.CompanyName != nil {
|
||||
query += fmt.Sprintf(", company_name = $%d", argIdx)
|
||||
args = append(args, *req.CompanyName)
|
||||
argIdx++
|
||||
}
|
||||
if req.SenderName != nil {
|
||||
query += fmt.Sprintf(", sender_name = $%d", argIdx)
|
||||
args = append(args, *req.SenderName)
|
||||
argIdx++
|
||||
}
|
||||
if req.SenderEmail != nil {
|
||||
query += fmt.Sprintf(", sender_email = $%d", argIdx)
|
||||
args = append(args, *req.SenderEmail)
|
||||
argIdx++
|
||||
}
|
||||
if req.ReplyToEmail != nil {
|
||||
query += fmt.Sprintf(", reply_to_email = $%d", argIdx)
|
||||
args = append(args, *req.ReplyToEmail)
|
||||
argIdx++
|
||||
}
|
||||
if req.FooterHTML != nil {
|
||||
query += fmt.Sprintf(", footer_html = $%d", argIdx)
|
||||
args = append(args, *req.FooterHTML)
|
||||
argIdx++
|
||||
}
|
||||
if req.FooterText != nil {
|
||||
query += fmt.Sprintf(", footer_text = $%d", argIdx)
|
||||
args = append(args, *req.FooterText)
|
||||
argIdx++
|
||||
}
|
||||
if req.PrimaryColor != nil {
|
||||
query += fmt.Sprintf(", primary_color = $%d", argIdx)
|
||||
args = append(args, *req.PrimaryColor)
|
||||
argIdx++
|
||||
}
|
||||
if req.SecondaryColor != nil {
|
||||
query += fmt.Sprintf(", secondary_color = $%d", argIdx)
|
||||
args = append(args, *req.SecondaryColor)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -334,210 +334,3 @@ func (s *GradeService) GetClassGradesBySubject(ctx context.Context, classID, sub
|
||||
return overviews, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade Statistics
|
||||
// ========================================
|
||||
|
||||
// GetStudentGradeAverage calculates the overall grade average for a student
|
||||
func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) {
|
||||
query := `
|
||||
SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0)
|
||||
FROM grades
|
||||
WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true`
|
||||
|
||||
var average float64
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to calculate average: %w", err)
|
||||
}
|
||||
|
||||
return average, nil
|
||||
}
|
||||
|
||||
// GetSubjectGradeStatistics gets grade statistics for a subject in a class
|
||||
func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT g.student_id) as student_count,
|
||||
AVG(g.value) as class_average,
|
||||
MIN(g.value) as best_grade,
|
||||
MAX(g.value) as worst_grade,
|
||||
COUNT(*) as total_grades
|
||||
FROM grades g
|
||||
JOIN students s ON g.student_id = s.id
|
||||
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true`
|
||||
|
||||
var studentCount, totalGrades int
|
||||
var classAverage, bestGrade, worstGrade float64
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan(
|
||||
&studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
||||
}
|
||||
|
||||
// Grade distribution (for German grades 1-6)
|
||||
distributionQuery := `
|
||||
SELECT
|
||||
COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1,
|
||||
COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2,
|
||||
COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3,
|
||||
COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4,
|
||||
COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5,
|
||||
COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6
|
||||
FROM grades g
|
||||
JOIN students s ON g.student_id = s.id
|
||||
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')`
|
||||
|
||||
var g1, g2, g3, g4, g5, g6 int
|
||||
err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan(
|
||||
&g1, &g2, &g3, &g4, &g5, &g6,
|
||||
)
|
||||
if err != nil {
|
||||
// Non-fatal, continue without distribution
|
||||
g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"student_count": studentCount,
|
||||
"class_average": classAverage,
|
||||
"best_grade": bestGrade,
|
||||
"worst_grade": worstGrade,
|
||||
"total_grades": totalGrades,
|
||||
"distribution": map[string]int{
|
||||
"1": g1,
|
||||
"2": g2,
|
||||
"3": g3,
|
||||
"4": g4,
|
||||
"5": g5,
|
||||
"6": g6,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade Comments
|
||||
// ========================================
|
||||
|
||||
// AddGradeComment adds a comment to a grade
|
||||
func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) {
|
||||
gradeComment := &models.GradeComment{
|
||||
ID: uuid.New(),
|
||||
GradeID: gradeID,
|
||||
TeacherID: teacherID,
|
||||
Comment: comment,
|
||||
IsPrivate: isPrivate,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID,
|
||||
gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt,
|
||||
).Scan(&gradeComment.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add grade comment: %w", err)
|
||||
}
|
||||
|
||||
return gradeComment, nil
|
||||
}
|
||||
|
||||
// GetGradeComments gets comments for a grade
|
||||
func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) {
|
||||
query := `
|
||||
SELECT id, grade_id, teacher_id, comment, is_private, created_at
|
||||
FROM grade_comments
|
||||
WHERE grade_id = $1`
|
||||
|
||||
if !includePrivate {
|
||||
query += ` AND is_private = false`
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, gradeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grade comments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []models.GradeComment
|
||||
for rows.Next() {
|
||||
var comment models.GradeComment
|
||||
err := rows.Scan(
|
||||
&comment.ID, &comment.GradeID, &comment.TeacherID,
|
||||
&comment.Comment, &comment.IsPrivate, &comment.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan grade comment: %w", err)
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Notifications
|
||||
// ========================================
|
||||
|
||||
func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) {
|
||||
if s.matrix == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get student info and Matrix room
|
||||
var studentFirstName, studentLastName, matrixDMRoom string
|
||||
err := s.db.Pool.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, matrix_dm_room
|
||||
FROM students
|
||||
WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
|
||||
if err != nil || matrixDMRoom == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get subject name
|
||||
var subjectName string
|
||||
err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
studentName := studentFirstName + " " + studentLastName
|
||||
gradeType := s.getGradeTypeDisplayName(grade.Type)
|
||||
|
||||
// Send Matrix notification
|
||||
err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to send grade notification: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GradeService) getGradeTypeDisplayName(gradeType string) string {
|
||||
switch gradeType {
|
||||
case models.GradeTypeExam:
|
||||
return "Klassenarbeit"
|
||||
case models.GradeTypeTest:
|
||||
return "Test"
|
||||
case models.GradeTypeOral:
|
||||
return "Mündliche Note"
|
||||
case models.GradeTypeHomework:
|
||||
return "Hausaufgabe"
|
||||
case models.GradeTypeProject:
|
||||
return "Projekt"
|
||||
case models.GradeTypeParticipation:
|
||||
return "Mitarbeit"
|
||||
case models.GradeTypeSemester:
|
||||
return "Halbjahreszeugnis"
|
||||
case models.GradeTypeFinal:
|
||||
return "Zeugnisnote"
|
||||
default:
|
||||
return gradeType
|
||||
}
|
||||
}
|
||||
|
||||
219
consent-service/internal/services/grade_service_ops.go
Normal file
219
consent-service/internal/services/grade_service_ops.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Grade Statistics
|
||||
// ========================================
|
||||
|
||||
// GetStudentGradeAverage calculates the overall grade average for a student
|
||||
func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) {
|
||||
query := `
|
||||
SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0)
|
||||
FROM grades
|
||||
WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true`
|
||||
|
||||
var average float64
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to calculate average: %w", err)
|
||||
}
|
||||
|
||||
return average, nil
|
||||
}
|
||||
|
||||
// GetSubjectGradeStatistics gets grade statistics for a subject in a class
|
||||
func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT g.student_id) as student_count,
|
||||
AVG(g.value) as class_average,
|
||||
MIN(g.value) as best_grade,
|
||||
MAX(g.value) as worst_grade,
|
||||
COUNT(*) as total_grades
|
||||
FROM grades g
|
||||
JOIN students s ON g.student_id = s.id
|
||||
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true`
|
||||
|
||||
var studentCount, totalGrades int
|
||||
var classAverage, bestGrade, worstGrade float64
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan(
|
||||
&studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
||||
}
|
||||
|
||||
// Grade distribution (for German grades 1-6)
|
||||
distributionQuery := `
|
||||
SELECT
|
||||
COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1,
|
||||
COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2,
|
||||
COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3,
|
||||
COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4,
|
||||
COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5,
|
||||
COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6
|
||||
FROM grades g
|
||||
JOIN students s ON g.student_id = s.id
|
||||
WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')`
|
||||
|
||||
var g1, g2, g3, g4, g5, g6 int
|
||||
err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan(
|
||||
&g1, &g2, &g3, &g4, &g5, &g6,
|
||||
)
|
||||
if err != nil {
|
||||
// Non-fatal, continue without distribution
|
||||
g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"student_count": studentCount,
|
||||
"class_average": classAverage,
|
||||
"best_grade": bestGrade,
|
||||
"worst_grade": worstGrade,
|
||||
"total_grades": totalGrades,
|
||||
"distribution": map[string]int{
|
||||
"1": g1,
|
||||
"2": g2,
|
||||
"3": g3,
|
||||
"4": g4,
|
||||
"5": g5,
|
||||
"6": g6,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Grade Comments
|
||||
// ========================================
|
||||
|
||||
// AddGradeComment adds a comment to a grade
|
||||
func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) {
|
||||
gradeComment := &models.GradeComment{
|
||||
ID: uuid.New(),
|
||||
GradeID: gradeID,
|
||||
TeacherID: teacherID,
|
||||
Comment: comment,
|
||||
IsPrivate: isPrivate,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID,
|
||||
gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt,
|
||||
).Scan(&gradeComment.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add grade comment: %w", err)
|
||||
}
|
||||
|
||||
return gradeComment, nil
|
||||
}
|
||||
|
||||
// GetGradeComments gets comments for a grade
|
||||
func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) {
|
||||
query := `
|
||||
SELECT id, grade_id, teacher_id, comment, is_private, created_at
|
||||
FROM grade_comments
|
||||
WHERE grade_id = $1`
|
||||
|
||||
if !includePrivate {
|
||||
query += ` AND is_private = false`
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, gradeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grade comments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []models.GradeComment
|
||||
for rows.Next() {
|
||||
var comment models.GradeComment
|
||||
err := rows.Scan(
|
||||
&comment.ID, &comment.GradeID, &comment.TeacherID,
|
||||
&comment.Comment, &comment.IsPrivate, &comment.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan grade comment: %w", err)
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Notifications
|
||||
// ========================================
|
||||
|
||||
func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) {
|
||||
if s.matrix == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get student info and Matrix room
|
||||
var studentFirstName, studentLastName, matrixDMRoom string
|
||||
err := s.db.Pool.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, matrix_dm_room
|
||||
FROM students
|
||||
WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom)
|
||||
if err != nil || matrixDMRoom == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get subject name
|
||||
var subjectName string
|
||||
err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
studentName := studentFirstName + " " + studentLastName
|
||||
gradeType := s.getGradeTypeDisplayName(grade.Type)
|
||||
|
||||
// Send Matrix notification
|
||||
err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to send grade notification: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GradeService) getGradeTypeDisplayName(gradeType string) string {
|
||||
switch gradeType {
|
||||
case models.GradeTypeExam:
|
||||
return "Klassenarbeit"
|
||||
case models.GradeTypeTest:
|
||||
return "Test"
|
||||
case models.GradeTypeOral:
|
||||
return "Mündliche Note"
|
||||
case models.GradeTypeHomework:
|
||||
return "Hausaufgabe"
|
||||
case models.GradeTypeProject:
|
||||
return "Projekt"
|
||||
case models.GradeTypeParticipation:
|
||||
return "Mitarbeit"
|
||||
case models.GradeTypeSemester:
|
||||
return "Halbjahreszeugnis"
|
||||
case models.GradeTypeFinal:
|
||||
return "Zeugnisnote"
|
||||
default:
|
||||
return gradeType
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,10 @@ package jitsi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JitsiService handles Jitsi Meet integration for video conferences
|
||||
@@ -292,275 +285,3 @@ func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string,
|
||||
return s.CreateMeetingLink(ctx, meeting)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// JWT Generation
|
||||
// ========================================
|
||||
|
||||
// generateJWT creates a signed JWT for Jitsi authentication
|
||||
func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) {
|
||||
if s.appSecret == "" {
|
||||
return "", nil, fmt.Errorf("app secret not configured")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Default expiration: 24 hours or based on meeting duration
|
||||
expiration := now.Add(24 * time.Hour)
|
||||
if meeting.Duration > 0 {
|
||||
expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute)
|
||||
}
|
||||
if meeting.StartTime != nil {
|
||||
expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute)
|
||||
}
|
||||
|
||||
claims := JWTClaims{
|
||||
Audience: "jitsi",
|
||||
Issuer: s.appID,
|
||||
Subject: "meet.jitsi",
|
||||
Room: roomName,
|
||||
ExpiresAt: expiration.Unix(),
|
||||
NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period
|
||||
Moderator: meeting.Moderator,
|
||||
Context: &JWTContext{
|
||||
User: &JWTUser{
|
||||
ID: uuid.New().String(),
|
||||
Name: meeting.DisplayName,
|
||||
Email: meeting.Email,
|
||||
Avatar: meeting.Avatar,
|
||||
Moderator: meeting.Moderator,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add features if specified
|
||||
if meeting.Features != nil {
|
||||
claims.Features = &JWTFeatures{
|
||||
Recording: boolToString(meeting.Features.Recording),
|
||||
Livestreaming: boolToString(meeting.Features.Livestreaming),
|
||||
Transcription: boolToString(meeting.Features.Transcription),
|
||||
OutboundCall: boolToString(meeting.Features.OutboundCall),
|
||||
}
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
token, err := s.signJWT(claims)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return token, &expiration, nil
|
||||
}
|
||||
|
||||
// signJWT creates and signs a JWT token
|
||||
func (s *JitsiService) signJWT(claims JWTClaims) (string, error) {
|
||||
// Header
|
||||
header := map[string]string{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Payload
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
// Sign
|
||||
message := headerB64 + "." + payloadB64
|
||||
h := hmac.New(sha256.New, []byte(s.appSecret))
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return message + "." + signature, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
// HealthCheck verifies the Jitsi server is accessible
|
||||
func (s *JitsiService) HealthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jitsi server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("jitsi server error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerInfo returns information about the Jitsi server
|
||||
func (s *JitsiService) GetServerInfo() map[string]string {
|
||||
return map[string]string{
|
||||
"base_url": s.baseURL,
|
||||
"app_id": s.appID,
|
||||
"auth_enabled": boolToString(s.appSecret != ""),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// URL Building
|
||||
// ========================================
|
||||
|
||||
// BuildEmbedURL creates an embeddable iframe URL
|
||||
func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string {
|
||||
params := url.Values{}
|
||||
|
||||
if displayName != "" {
|
||||
params.Set("userInfo.displayName", displayName)
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
if config.StartWithAudioMuted {
|
||||
params.Set("config.startWithAudioMuted", "true")
|
||||
}
|
||||
if config.StartWithVideoMuted {
|
||||
params.Set("config.startWithVideoMuted", "true")
|
||||
}
|
||||
if config.DisableDeepLinking {
|
||||
params.Set("config.disableDeepLinking", "true")
|
||||
}
|
||||
}
|
||||
|
||||
embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName))
|
||||
if len(params) > 0 {
|
||||
embedURL += "#" + params.Encode()
|
||||
}
|
||||
|
||||
return embedURL
|
||||
}
|
||||
|
||||
// BuildIFrameCode generates HTML iframe code for embedding
|
||||
func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string {
|
||||
if width == 0 {
|
||||
width = 800
|
||||
}
|
||||
if height == 0 {
|
||||
height = 600
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<iframe src="%s/%s" width="%d" height="%d" allow="camera; microphone; fullscreen; display-capture; autoplay" style="border: 0;"></iframe>`,
|
||||
s.baseURL,
|
||||
s.sanitizeRoomName(roomName),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
// generateRoomName creates a unique room name
|
||||
func (s *JitsiService) generateRoomName() string {
|
||||
return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8])
|
||||
}
|
||||
|
||||
// generateTrainingRoomName creates a room name for training sessions
|
||||
func (s *JitsiService) generateTrainingRoomName(title string) string {
|
||||
sanitized := s.sanitizeRoomName(title)
|
||||
if sanitized == "" {
|
||||
sanitized = "schulung"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504"))
|
||||
}
|
||||
|
||||
// sanitizeRoomName removes invalid characters from room names
|
||||
func (s *JitsiService) sanitizeRoomName(name string) string {
|
||||
// Replace spaces and special characters
|
||||
result := strings.ToLower(name)
|
||||
result = strings.ReplaceAll(result, " ", "-")
|
||||
result = strings.ReplaceAll(result, "ä", "ae")
|
||||
result = strings.ReplaceAll(result, "ö", "oe")
|
||||
result = strings.ReplaceAll(result, "ü", "ue")
|
||||
result = strings.ReplaceAll(result, "ß", "ss")
|
||||
|
||||
// Remove any remaining non-alphanumeric characters except hyphen
|
||||
var cleaned strings.Builder
|
||||
for _, r := range result {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
cleaned.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove consecutive hyphens
|
||||
result = cleaned.String()
|
||||
for strings.Contains(result, "--") {
|
||||
result = strings.ReplaceAll(result, "--", "-")
|
||||
}
|
||||
|
||||
// Trim hyphens from start and end
|
||||
result = strings.Trim(result, "-")
|
||||
|
||||
// Limit length
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// generatePassword creates a random meeting password
|
||||
func (s *JitsiService) generatePassword() string {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
// buildConfigParams creates URL parameters from config
|
||||
func (s *JitsiService) buildConfigParams(config *MeetingConfig) string {
|
||||
params := url.Values{}
|
||||
|
||||
if config.StartWithAudioMuted {
|
||||
params.Set("config.startWithAudioMuted", "true")
|
||||
}
|
||||
if config.StartWithVideoMuted {
|
||||
params.Set("config.startWithVideoMuted", "true")
|
||||
}
|
||||
if config.DisableDeepLinking {
|
||||
params.Set("config.disableDeepLinking", "true")
|
||||
}
|
||||
if config.RequireDisplayName {
|
||||
params.Set("config.requireDisplayName", "true")
|
||||
}
|
||||
if config.EnableLobby {
|
||||
params.Set("config.enableLobby", "true")
|
||||
}
|
||||
|
||||
return params.Encode()
|
||||
}
|
||||
|
||||
// boolToString converts bool to "true"/"false" string
|
||||
func boolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// GetBaseURL returns the configured base URL
|
||||
func (s *JitsiService) GetBaseURL() string {
|
||||
return s.baseURL
|
||||
}
|
||||
|
||||
// IsAuthEnabled returns whether JWT authentication is configured
|
||||
func (s *JitsiService) IsAuthEnabled() bool {
|
||||
return s.appSecret != ""
|
||||
}
|
||||
|
||||
290
consent-service/internal/services/jitsi/jitsi_service_helpers.go
Normal file
290
consent-service/internal/services/jitsi/jitsi_service_helpers.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package jitsi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// JWT Generation
|
||||
// ========================================
|
||||
|
||||
// generateJWT creates a signed JWT for Jitsi authentication
|
||||
func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) {
|
||||
if s.appSecret == "" {
|
||||
return "", nil, fmt.Errorf("app secret not configured")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Default expiration: 24 hours or based on meeting duration
|
||||
expiration := now.Add(24 * time.Hour)
|
||||
if meeting.Duration > 0 {
|
||||
expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute)
|
||||
}
|
||||
if meeting.StartTime != nil {
|
||||
expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute)
|
||||
}
|
||||
|
||||
claims := JWTClaims{
|
||||
Audience: "jitsi",
|
||||
Issuer: s.appID,
|
||||
Subject: "meet.jitsi",
|
||||
Room: roomName,
|
||||
ExpiresAt: expiration.Unix(),
|
||||
NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period
|
||||
Moderator: meeting.Moderator,
|
||||
Context: &JWTContext{
|
||||
User: &JWTUser{
|
||||
ID: uuid.New().String(),
|
||||
Name: meeting.DisplayName,
|
||||
Email: meeting.Email,
|
||||
Avatar: meeting.Avatar,
|
||||
Moderator: meeting.Moderator,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add features if specified
|
||||
if meeting.Features != nil {
|
||||
claims.Features = &JWTFeatures{
|
||||
Recording: boolToString(meeting.Features.Recording),
|
||||
Livestreaming: boolToString(meeting.Features.Livestreaming),
|
||||
Transcription: boolToString(meeting.Features.Transcription),
|
||||
OutboundCall: boolToString(meeting.Features.OutboundCall),
|
||||
}
|
||||
}
|
||||
|
||||
// Create JWT
|
||||
token, err := s.signJWT(claims)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return token, &expiration, nil
|
||||
}
|
||||
|
||||
// signJWT creates and signs a JWT token
|
||||
func (s *JitsiService) signJWT(claims JWTClaims) (string, error) {
|
||||
// Header
|
||||
header := map[string]string{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Payload
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
// Sign
|
||||
message := headerB64 + "." + payloadB64
|
||||
h := hmac.New(sha256.New, []byte(s.appSecret))
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return message + "." + signature, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
// HealthCheck verifies the Jitsi server is accessible
|
||||
func (s *JitsiService) HealthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jitsi server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("jitsi server error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerInfo returns information about the Jitsi server
|
||||
func (s *JitsiService) GetServerInfo() map[string]string {
|
||||
return map[string]string{
|
||||
"base_url": s.baseURL,
|
||||
"app_id": s.appID,
|
||||
"auth_enabled": boolToString(s.appSecret != ""),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// URL Building
|
||||
// ========================================
|
||||
|
||||
// BuildEmbedURL creates an embeddable iframe URL
|
||||
func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string {
|
||||
params := url.Values{}
|
||||
|
||||
if displayName != "" {
|
||||
params.Set("userInfo.displayName", displayName)
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
if config.StartWithAudioMuted {
|
||||
params.Set("config.startWithAudioMuted", "true")
|
||||
}
|
||||
if config.StartWithVideoMuted {
|
||||
params.Set("config.startWithVideoMuted", "true")
|
||||
}
|
||||
if config.DisableDeepLinking {
|
||||
params.Set("config.disableDeepLinking", "true")
|
||||
}
|
||||
}
|
||||
|
||||
embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName))
|
||||
if len(params) > 0 {
|
||||
embedURL += "#" + params.Encode()
|
||||
}
|
||||
|
||||
return embedURL
|
||||
}
|
||||
|
||||
// BuildIFrameCode generates HTML iframe code for embedding
|
||||
func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string {
|
||||
if width == 0 {
|
||||
width = 800
|
||||
}
|
||||
if height == 0 {
|
||||
height = 600
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<iframe src="%s/%s" width="%d" height="%d" allow="camera; microphone; fullscreen; display-capture; autoplay" style="border: 0;"></iframe>`,
|
||||
s.baseURL,
|
||||
s.sanitizeRoomName(roomName),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
// generateRoomName creates a unique room name
|
||||
func (s *JitsiService) generateRoomName() string {
|
||||
return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8])
|
||||
}
|
||||
|
||||
// generateTrainingRoomName creates a room name for training sessions
|
||||
func (s *JitsiService) generateTrainingRoomName(title string) string {
|
||||
sanitized := s.sanitizeRoomName(title)
|
||||
if sanitized == "" {
|
||||
sanitized = "schulung"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504"))
|
||||
}
|
||||
|
||||
// sanitizeRoomName removes invalid characters from room names
|
||||
func (s *JitsiService) sanitizeRoomName(name string) string {
|
||||
// Replace spaces and special characters
|
||||
result := strings.ToLower(name)
|
||||
result = strings.ReplaceAll(result, " ", "-")
|
||||
result = strings.ReplaceAll(result, "ä", "ae")
|
||||
result = strings.ReplaceAll(result, "ö", "oe")
|
||||
result = strings.ReplaceAll(result, "ü", "ue")
|
||||
result = strings.ReplaceAll(result, "ß", "ss")
|
||||
|
||||
// Remove any remaining non-alphanumeric characters except hyphen
|
||||
var cleaned strings.Builder
|
||||
for _, r := range result {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
cleaned.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove consecutive hyphens
|
||||
result = cleaned.String()
|
||||
for strings.Contains(result, "--") {
|
||||
result = strings.ReplaceAll(result, "--", "-")
|
||||
}
|
||||
|
||||
// Trim hyphens from start and end
|
||||
result = strings.Trim(result, "-")
|
||||
|
||||
// Limit length
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// generatePassword creates a random meeting password
|
||||
func (s *JitsiService) generatePassword() string {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
// buildConfigParams creates URL parameters from config
|
||||
func (s *JitsiService) buildConfigParams(config *MeetingConfig) string {
|
||||
params := url.Values{}
|
||||
|
||||
if config.StartWithAudioMuted {
|
||||
params.Set("config.startWithAudioMuted", "true")
|
||||
}
|
||||
if config.StartWithVideoMuted {
|
||||
params.Set("config.startWithVideoMuted", "true")
|
||||
}
|
||||
if config.DisableDeepLinking {
|
||||
params.Set("config.disableDeepLinking", "true")
|
||||
}
|
||||
if config.RequireDisplayName {
|
||||
params.Set("config.requireDisplayName", "true")
|
||||
}
|
||||
if config.EnableLobby {
|
||||
params.Set("config.enableLobby", "true")
|
||||
}
|
||||
|
||||
return params.Encode()
|
||||
}
|
||||
|
||||
// boolToString converts bool to "true"/"false" string
|
||||
func boolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// GetBaseURL returns the configured base URL
|
||||
func (s *JitsiService) GetBaseURL() string {
|
||||
return s.baseURL
|
||||
}
|
||||
|
||||
// IsAuthEnabled returns whether JWT authentication is configured
|
||||
func (s *JitsiService) IsAuthEnabled() bool {
|
||||
return s.appSecret != ""
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -392,157 +390,3 @@ func (s *MatrixService) SetUserPowerLevel(ctx context.Context, roomID string, us
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Messaging
|
||||
// ========================================
|
||||
|
||||
// SendMessage sends a text message to a room
|
||||
func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error {
|
||||
req := SendMessageRequest{
|
||||
MsgType: "m.text",
|
||||
Body: message,
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "m.room.message", req)
|
||||
}
|
||||
|
||||
// SendHTMLMessage sends an HTML-formatted message to a room
|
||||
func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error {
|
||||
req := SendMessageRequest{
|
||||
MsgType: "m.text",
|
||||
Body: plainText,
|
||||
Format: "org.matrix.custom.html",
|
||||
FormattedBody: htmlBody,
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "m.room.message", req)
|
||||
}
|
||||
|
||||
// SendAbsenceNotification sends an absence notification to parents
|
||||
func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error {
|
||||
plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>⚠️ Abwesenheitsmeldung</h3>
|
||||
<p>Ihr Kind <strong>%s</strong> war heute (%s) in der <strong>%d. Stunde</strong> nicht im Unterricht anwesend.</p>
|
||||
<p>Bitte bestätigen Sie den Grund der Abwesenheit.</p>
|
||||
<ul>
|
||||
<li>✅ Entschuldigt (Krankheit)</li>
|
||||
<li>📋 Arztbesuch</li>
|
||||
<li>❓ Sonstiges (bitte erläutern)</li>
|
||||
</ul>`, studentName, date, lessonNumber)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// SendGradeNotification sends a grade notification to parents
|
||||
func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error {
|
||||
plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>📊 Neue Note eingetragen</h3>
|
||||
<p>Für <strong>%s</strong> wurde eine neue Note eingetragen:</p>
|
||||
<table>
|
||||
<tr><td>Fach:</td><td><strong>%s</strong></td></tr>
|
||||
<tr><td>Art:</td><td>%s</td></tr>
|
||||
<tr><td>Note:</td><td><strong>%.1f</strong></td></tr>
|
||||
</table>`, studentName, subject, gradeType, grade)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// SendClassAnnouncement sends an announcement to a class info room
|
||||
func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error {
|
||||
plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>📢 %s</h3>
|
||||
<p>%s</p>
|
||||
<p><em>— %s</em></p>`, title, content, teacherName)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Internal Helpers
|
||||
// ========================================
|
||||
|
||||
func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error {
|
||||
body, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal content: %w", err)
|
||||
}
|
||||
|
||||
txnID := uuid.New().String()
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
|
||||
url.PathEscape(roomID), url.PathEscape(eventType), txnID)
|
||||
|
||||
resp, err := s.doRequest(ctx, "PUT", endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send event: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) {
|
||||
fullURL := s.homeserverURL + endpoint
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return s.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (s *MatrixService) parseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp struct {
|
||||
ErrCode string `json:"errcode"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
// HealthCheck checks if the Matrix server is reachable
|
||||
func (s *MatrixService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("matrix server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerName returns the configured server name
|
||||
func (s *MatrixService) GetServerName() string {
|
||||
return s.serverName
|
||||
}
|
||||
|
||||
// GenerateUserID generates a Matrix user ID from a username
|
||||
func (s *MatrixService) GenerateUserID(username string) string {
|
||||
return fmt.Sprintf("@%s:%s", username, s.serverName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Messaging
|
||||
// ========================================
|
||||
|
||||
// SendMessage sends a text message to a room
|
||||
func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error {
|
||||
req := SendMessageRequest{
|
||||
MsgType: "m.text",
|
||||
Body: message,
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "m.room.message", req)
|
||||
}
|
||||
|
||||
// SendHTMLMessage sends an HTML-formatted message to a room
|
||||
func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error {
|
||||
req := SendMessageRequest{
|
||||
MsgType: "m.text",
|
||||
Body: plainText,
|
||||
Format: "org.matrix.custom.html",
|
||||
FormattedBody: htmlBody,
|
||||
}
|
||||
|
||||
return s.sendEvent(ctx, roomID, "m.room.message", req)
|
||||
}
|
||||
|
||||
// SendAbsenceNotification sends an absence notification to parents
|
||||
func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error {
|
||||
plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>⚠️ Abwesenheitsmeldung</h3>
|
||||
<p>Ihr Kind <strong>%s</strong> war heute (%s) in der <strong>%d. Stunde</strong> nicht im Unterricht anwesend.</p>
|
||||
<p>Bitte bestätigen Sie den Grund der Abwesenheit.</p>
|
||||
<ul>
|
||||
<li>✅ Entschuldigt (Krankheit)</li>
|
||||
<li>📋 Arztbesuch</li>
|
||||
<li>❓ Sonstiges (bitte erläutern)</li>
|
||||
</ul>`, studentName, date, lessonNumber)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// SendGradeNotification sends a grade notification to parents
|
||||
func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error {
|
||||
plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>📊 Neue Note eingetragen</h3>
|
||||
<p>Für <strong>%s</strong> wurde eine neue Note eingetragen:</p>
|
||||
<table>
|
||||
<tr><td>Fach:</td><td><strong>%s</strong></td></tr>
|
||||
<tr><td>Art:</td><td>%s</td></tr>
|
||||
<tr><td>Note:</td><td><strong>%.1f</strong></td></tr>
|
||||
</table>`, studentName, subject, gradeType, grade)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// SendClassAnnouncement sends an announcement to a class info room
|
||||
func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error {
|
||||
plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName)
|
||||
|
||||
htmlBody := fmt.Sprintf(`<h3>📢 %s</h3>
|
||||
<p>%s</p>
|
||||
<p><em>— %s</em></p>`, title, content, teacherName)
|
||||
|
||||
return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Internal Helpers
|
||||
// ========================================
|
||||
|
||||
func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error {
|
||||
body, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal content: %w", err)
|
||||
}
|
||||
|
||||
txnID := uuid.New().String()
|
||||
endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
|
||||
url.PathEscape(roomID), url.PathEscape(eventType), txnID)
|
||||
|
||||
resp, err := s.doRequest(ctx, "PUT", endpoint, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send event: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) {
|
||||
fullURL := s.homeserverURL + endpoint
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return s.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (s *MatrixService) parseError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp struct {
|
||||
ErrCode string `json:"errcode"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Health Check
|
||||
// ========================================
|
||||
|
||||
// HealthCheck checks if the Matrix server is reachable
|
||||
func (s *MatrixService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("matrix server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServerName returns the configured server name
|
||||
func (s *MatrixService) GetServerName() string {
|
||||
return s.serverName
|
||||
}
|
||||
|
||||
// GenerateUserID generates a Matrix user ID from a username
|
||||
func (s *MatrixService) GenerateUserID(username string) string {
|
||||
return fmt.Sprintf("@%s:%s", username, s.serverName)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -20,25 +19,25 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidClient = errors.New("invalid_client")
|
||||
ErrInvalidGrant = errors.New("invalid_grant")
|
||||
ErrInvalidScope = errors.New("invalid_scope")
|
||||
ErrInvalidRequest = errors.New("invalid_request")
|
||||
ErrUnauthorizedClient = errors.New("unauthorized_client")
|
||||
ErrAccessDenied = errors.New("access_denied")
|
||||
ErrInvalidRedirectURI = errors.New("invalid redirect_uri")
|
||||
ErrCodeExpired = errors.New("authorization code expired")
|
||||
ErrCodeUsed = errors.New("authorization code already used")
|
||||
ErrPKCERequired = errors.New("PKCE code_challenge required for public clients")
|
||||
ErrPKCEVerifyFailed = errors.New("PKCE verification failed")
|
||||
ErrInvalidClient = errors.New("invalid_client")
|
||||
ErrInvalidGrant = errors.New("invalid_grant")
|
||||
ErrInvalidScope = errors.New("invalid_scope")
|
||||
ErrInvalidRequest = errors.New("invalid_request")
|
||||
ErrUnauthorizedClient = errors.New("unauthorized_client")
|
||||
ErrAccessDenied = errors.New("access_denied")
|
||||
ErrInvalidRedirectURI = errors.New("invalid redirect_uri")
|
||||
ErrCodeExpired = errors.New("authorization code expired")
|
||||
ErrCodeUsed = errors.New("authorization code already used")
|
||||
ErrPKCERequired = errors.New("PKCE code_challenge required for public clients")
|
||||
ErrPKCEVerifyFailed = errors.New("PKCE verification failed")
|
||||
)
|
||||
|
||||
// OAuthService handles OAuth 2.0 Authorization Code Flow with PKCE
|
||||
type OAuthService struct {
|
||||
db *pgxpool.Pool
|
||||
jwtSecret string
|
||||
authCodeExpiration time.Duration
|
||||
accessTokenExpiration time.Duration
|
||||
db *pgxpool.Pool
|
||||
jwtSecret string
|
||||
authCodeExpiration time.Duration
|
||||
accessTokenExpiration time.Duration
|
||||
refreshTokenExpiration time.Duration
|
||||
}
|
||||
|
||||
@@ -47,12 +46,16 @@ func NewOAuthService(db *pgxpool.Pool, jwtSecret string) *OAuthService {
|
||||
return &OAuthService{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly
|
||||
accessTokenExpiration: time.Hour, // 1 hour
|
||||
refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days
|
||||
authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly
|
||||
accessTokenExpiration: time.Hour, // 1 hour
|
||||
refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Client Validation
|
||||
// ========================================
|
||||
|
||||
// ValidateClient validates an OAuth client
|
||||
func (s *OAuthService) ValidateClient(ctx context.Context, clientID string) (*models.OAuthClient, error) {
|
||||
var client models.OAuthClient
|
||||
@@ -133,6 +136,10 @@ func (s *OAuthService) ValidateScopes(client *models.OAuthClient, requestedScope
|
||||
return validScopes, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Authorization Code
|
||||
// ========================================
|
||||
|
||||
// GenerateAuthorizationCode generates a new authorization code
|
||||
func (s *OAuthService) GenerateAuthorizationCode(
|
||||
ctx context.Context,
|
||||
@@ -181,295 +188,9 @@ func (s *OAuthService) GenerateAuthorizationCode(
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ExchangeAuthorizationCode exchanges an authorization code for tokens
|
||||
func (s *OAuthService) ExchangeAuthorizationCode(
|
||||
ctx context.Context,
|
||||
code string,
|
||||
clientID string,
|
||||
redirectURI string,
|
||||
codeVerifier string,
|
||||
) (*models.OAuthTokenResponse, error) {
|
||||
// Hash the code to look it up
|
||||
codeHash := sha256.Sum256([]byte(code))
|
||||
hashedCode := hex.EncodeToString(codeHash[:])
|
||||
|
||||
var authCode models.OAuthAuthorizationCode
|
||||
var scopesJSON []byte
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at
|
||||
FROM oauth_authorization_codes WHERE code = $1
|
||||
`, hashedCode).Scan(
|
||||
&authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI,
|
||||
&scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod,
|
||||
&authCode.ExpiresAt, &authCode.UsedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Check if code was already used
|
||||
if authCode.UsedAt != nil {
|
||||
return nil, ErrCodeUsed
|
||||
}
|
||||
|
||||
// Check if code is expired
|
||||
if time.Now().After(authCode.ExpiresAt) {
|
||||
return nil, ErrCodeExpired
|
||||
}
|
||||
|
||||
// Verify client_id matches
|
||||
if authCode.ClientID != clientID {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Verify redirect_uri matches
|
||||
if authCode.RedirectURI != redirectURI {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Verify PKCE if code_challenge was provided
|
||||
if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" {
|
||||
if codeVerifier == "" {
|
||||
return nil, ErrPKCEVerifyFailed
|
||||
}
|
||||
|
||||
var expectedChallenge string
|
||||
if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" {
|
||||
// SHA256 hash of verifier
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
} else {
|
||||
// Plain method
|
||||
expectedChallenge = codeVerifier
|
||||
}
|
||||
|
||||
if expectedChallenge != *authCode.CodeChallenge {
|
||||
return nil, ErrPKCEVerifyFailed
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
_, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to mark code as used: %w", err)
|
||||
}
|
||||
|
||||
// Parse scopes
|
||||
var scopes []string
|
||||
json.Unmarshal(scopesJSON, &scopes)
|
||||
|
||||
// Generate tokens
|
||||
return s.generateTokens(ctx, clientID, authCode.UserID, scopes)
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes an access token using a refresh token
|
||||
func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) {
|
||||
// Hash the refresh token
|
||||
tokenHash := sha256.Sum256([]byte(refreshToken))
|
||||
hashedToken := hex.EncodeToString(tokenHash[:])
|
||||
|
||||
var rt models.OAuthRefreshToken
|
||||
var scopesJSON []byte
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, client_id, user_id, scopes, expires_at, revoked_at
|
||||
FROM oauth_refresh_tokens WHERE token_hash = $1
|
||||
`, hashedToken).Scan(
|
||||
&rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if rt.RevokedAt != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(rt.ExpiresAt) {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Verify client_id matches
|
||||
if rt.ClientID != clientID {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Parse original scopes
|
||||
var originalScopes []string
|
||||
json.Unmarshal(scopesJSON, &originalScopes)
|
||||
|
||||
// Determine scopes for new tokens
|
||||
var scopes []string
|
||||
if requestedScope != "" {
|
||||
// Validate that requested scopes are subset of original scopes
|
||||
originalMap := make(map[string]bool)
|
||||
for _, s := range originalScopes {
|
||||
originalMap[s] = true
|
||||
}
|
||||
|
||||
for _, s := range strings.Split(requestedScope, " ") {
|
||||
if originalMap[s] {
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(scopes) == 0 {
|
||||
return nil, ErrInvalidScope
|
||||
}
|
||||
} else {
|
||||
scopes = originalScopes
|
||||
}
|
||||
|
||||
// Revoke old refresh token (rotate)
|
||||
_, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID)
|
||||
|
||||
// Generate new tokens
|
||||
return s.generateTokens(ctx, clientID, rt.UserID, scopes)
|
||||
}
|
||||
|
||||
// generateTokens generates access and refresh tokens
|
||||
func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) {
|
||||
// Get user info for JWT
|
||||
var user models.User
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, email, name, role, account_status FROM users WHERE id = $1
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Generate access token (JWT)
|
||||
accessTokenClaims := jwt.MapClaims{
|
||||
"sub": userID.String(),
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"account_status": user.AccountStatus,
|
||||
"client_id": clientID,
|
||||
"scope": strings.Join(scopes, " "),
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(s.accessTokenExpiration).Unix(),
|
||||
"iss": "breakpilot-consent-service",
|
||||
"aud": clientID,
|
||||
}
|
||||
|
||||
if user.Name != nil {
|
||||
accessTokenClaims["name"] = *user.Name
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims)
|
||||
accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign access token: %w", err)
|
||||
}
|
||||
|
||||
// Hash access token for storage
|
||||
accessTokenHash := sha256.Sum256([]byte(accessTokenString))
|
||||
hashedAccessToken := hex.EncodeToString(accessTokenHash[:])
|
||||
|
||||
scopesJSON, _ := json.Marshal(scopes)
|
||||
|
||||
// Store access token
|
||||
var accessTokenID uuid.UUID
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate refresh token (opaque)
|
||||
refreshTokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(refreshTokenBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
||||
}
|
||||
refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes)
|
||||
|
||||
// Hash refresh token for storage
|
||||
refreshTokenHash := sha256.Sum256([]byte(refreshTokenString))
|
||||
hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:])
|
||||
|
||||
// Store refresh token
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &models.OAuthTokenResponse{
|
||||
AccessToken: accessTokenString,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(s.accessTokenExpiration.Seconds()),
|
||||
RefreshToken: refreshTokenString,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeToken revokes an access or refresh token
|
||||
func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error {
|
||||
tokenHash := sha256.Sum256([]byte(token))
|
||||
hashedToken := hex.EncodeToString(tokenHash[:])
|
||||
|
||||
// Try to revoke as access token
|
||||
if tokenTypeHint == "" || tokenTypeHint == "access_token" {
|
||||
result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
|
||||
if err == nil && result.RowsAffected() > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try to revoke as refresh token
|
||||
if tokenTypeHint == "" || tokenTypeHint == "refresh_token" {
|
||||
result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
|
||||
if err == nil && result.RowsAffected() > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil // RFC 7009: Always return success
|
||||
}
|
||||
|
||||
// ValidateAccessToken validates an OAuth access token
|
||||
func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Check if token is revoked in database
|
||||
tokenHash := sha256.Sum256([]byte(tokenString))
|
||||
hashedToken := hex.EncodeToString(tokenHash[:])
|
||||
|
||||
var revokedAt *time.Time
|
||||
err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt)
|
||||
if err == nil && revokedAt != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
// ========================================
|
||||
// Client Management (Admin)
|
||||
// ========================================
|
||||
|
||||
// GetClientByID retrieves an OAuth client by its client_id
|
||||
func (s *OAuthService) GetClientByID(ctx context.Context, clientID string) (*models.OAuthClient, error) {
|
||||
|
||||
308
consent-service/internal/services/oauth_token_service.go
Normal file
308
consent-service/internal/services/oauth_token_service.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
)
|
||||
|
||||
// ExchangeAuthorizationCode exchanges an authorization code for tokens
|
||||
func (s *OAuthService) ExchangeAuthorizationCode(
|
||||
ctx context.Context,
|
||||
code string,
|
||||
clientID string,
|
||||
redirectURI string,
|
||||
codeVerifier string,
|
||||
) (*models.OAuthTokenResponse, error) {
|
||||
// Hash the code to look it up
|
||||
codeHash := sha256.Sum256([]byte(code))
|
||||
hashedCode := hex.EncodeToString(codeHash[:])
|
||||
|
||||
var authCode models.OAuthAuthorizationCode
|
||||
var scopesJSON []byte
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at
|
||||
FROM oauth_authorization_codes WHERE code = $1
|
||||
`, hashedCode).Scan(
|
||||
&authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI,
|
||||
&scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod,
|
||||
&authCode.ExpiresAt, &authCode.UsedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Check if code was already used
|
||||
if authCode.UsedAt != nil {
|
||||
return nil, ErrCodeUsed
|
||||
}
|
||||
|
||||
// Check if code is expired
|
||||
if time.Now().After(authCode.ExpiresAt) {
|
||||
return nil, ErrCodeExpired
|
||||
}
|
||||
|
||||
// Verify client_id matches
|
||||
if authCode.ClientID != clientID {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Verify redirect_uri matches
|
||||
if authCode.RedirectURI != redirectURI {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Verify PKCE if code_challenge was provided
|
||||
if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" {
|
||||
if codeVerifier == "" {
|
||||
return nil, ErrPKCEVerifyFailed
|
||||
}
|
||||
|
||||
var expectedChallenge string
|
||||
if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" {
|
||||
// SHA256 hash of verifier
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
} else {
|
||||
// Plain method
|
||||
expectedChallenge = codeVerifier
|
||||
}
|
||||
|
||||
if expectedChallenge != *authCode.CodeChallenge {
|
||||
return nil, ErrPKCEVerifyFailed
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
_, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to mark code as used: %w", err)
|
||||
}
|
||||
|
||||
// Parse scopes
|
||||
var scopes []string
|
||||
json.Unmarshal(scopesJSON, &scopes)
|
||||
|
||||
// Generate tokens
|
||||
return s.generateTokens(ctx, clientID, authCode.UserID, scopes)
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes an access token using a refresh token
|
||||
func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) {
|
||||
// Hash the refresh token
|
||||
tokenHash := sha256.Sum256([]byte(refreshToken))
|
||||
hashedToken := hex.EncodeToString(tokenHash[:])
|
||||
|
||||
var rt models.OAuthRefreshToken
|
||||
var scopesJSON []byte
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, client_id, user_id, scopes, expires_at, revoked_at
|
||||
FROM oauth_refresh_tokens WHERE token_hash = $1
|
||||
`, hashedToken).Scan(
|
||||
&rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if rt.RevokedAt != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(rt.ExpiresAt) {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Verify client_id matches
|
||||
if rt.ClientID != clientID {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Parse original scopes
|
||||
var originalScopes []string
|
||||
json.Unmarshal(scopesJSON, &originalScopes)
|
||||
|
||||
// Determine scopes for new tokens
|
||||
var scopes []string
|
||||
if requestedScope != "" {
|
||||
// Validate that requested scopes are subset of original scopes
|
||||
originalMap := make(map[string]bool)
|
||||
for _, s := range originalScopes {
|
||||
originalMap[s] = true
|
||||
}
|
||||
|
||||
for _, s := range strings.Split(requestedScope, " ") {
|
||||
if originalMap[s] {
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(scopes) == 0 {
|
||||
return nil, ErrInvalidScope
|
||||
}
|
||||
} else {
|
||||
scopes = originalScopes
|
||||
}
|
||||
|
||||
// Revoke old refresh token (rotate)
|
||||
_, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID)
|
||||
|
||||
// Generate new tokens
|
||||
return s.generateTokens(ctx, clientID, rt.UserID, scopes)
|
||||
}
|
||||
|
||||
// generateTokens generates access and refresh tokens
|
||||
func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) {
|
||||
// Get user info for JWT
|
||||
var user models.User
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, email, name, role, account_status FROM users WHERE id = $1
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus)
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidGrant
|
||||
}
|
||||
|
||||
// Generate access token (JWT)
|
||||
accessTokenClaims := jwt.MapClaims{
|
||||
"sub": userID.String(),
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"account_status": user.AccountStatus,
|
||||
"client_id": clientID,
|
||||
"scope": strings.Join(scopes, " "),
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(s.accessTokenExpiration).Unix(),
|
||||
"iss": "breakpilot-consent-service",
|
||||
"aud": clientID,
|
||||
}
|
||||
|
||||
if user.Name != nil {
|
||||
accessTokenClaims["name"] = *user.Name
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims)
|
||||
accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign access token: %w", err)
|
||||
}
|
||||
|
||||
// Hash access token for storage
|
||||
accessTokenHash := sha256.Sum256([]byte(accessTokenString))
|
||||
hashedAccessToken := hex.EncodeToString(accessTokenHash[:])
|
||||
|
||||
scopesJSON, _ := json.Marshal(scopes)
|
||||
|
||||
// Store access token
|
||||
var accessTokenID uuid.UUID
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store access token: %w", err)
|
||||
}
|
||||
|
||||
// Generate refresh token (opaque)
|
||||
refreshTokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(refreshTokenBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
||||
}
|
||||
refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes)
|
||||
|
||||
// Hash refresh token for storage
|
||||
refreshTokenHash := sha256.Sum256([]byte(refreshTokenString))
|
||||
hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:])
|
||||
|
||||
// Store refresh token
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &models.OAuthTokenResponse{
|
||||
AccessToken: accessTokenString,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(s.accessTokenExpiration.Seconds()),
|
||||
RefreshToken: refreshTokenString,
|
||||
Scope: strings.Join(scopes, " "),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeToken revokes an access or refresh token
|
||||
func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error {
|
||||
tokenHash := sha256.Sum256([]byte(token))
|
||||
hashedToken := hex.EncodeToString(tokenHash[:])
|
||||
|
||||
// Try to revoke as access token
|
||||
if tokenTypeHint == "" || tokenTypeHint == "access_token" {
|
||||
result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
|
||||
if err == nil && result.RowsAffected() > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try to revoke as refresh token
|
||||
if tokenTypeHint == "" || tokenTypeHint == "refresh_token" {
|
||||
result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken)
|
||||
if err == nil && result.RowsAffected() > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil // RFC 7009: Always return success
|
||||
}
|
||||
|
||||
// ValidateAccessToken validates an OAuth access token
|
||||
func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Check if token is revoked in database
|
||||
tokenHash := sha256.Sum256([]byte(tokenString))
|
||||
hashedToken := hex.EncodeToString(tokenHash[:])
|
||||
|
||||
var revokedAt *time.Time
|
||||
err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt)
|
||||
if err == nil && revokedAt != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -35,21 +33,21 @@ func NewSchoolService(db *database.DB, matrixService *matrix.MatrixService) *Sch
|
||||
// CreateSchool creates a new school
|
||||
func (s *SchoolService) CreateSchool(ctx context.Context, req models.CreateSchoolRequest) (*models.School, error) {
|
||||
school := &models.School{
|
||||
ID: uuid.New(),
|
||||
Name: req.Name,
|
||||
ShortName: req.ShortName,
|
||||
Type: req.Type,
|
||||
Address: req.Address,
|
||||
City: req.City,
|
||||
ID: uuid.New(),
|
||||
Name: req.Name,
|
||||
ShortName: req.ShortName,
|
||||
Type: req.Type,
|
||||
Address: req.Address,
|
||||
City: req.City,
|
||||
PostalCode: req.PostalCode,
|
||||
State: req.State,
|
||||
Country: "DE",
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
Website: req.Website,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
State: req.State,
|
||||
Country: "DE",
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
Website: req.Website,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
@@ -298,350 +296,6 @@ func (s *SchoolService) ListClasses(ctx context.Context, schoolID, schoolYearID
|
||||
return classes, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Student Management
|
||||
// ========================================
|
||||
|
||||
// CreateStudent creates a new student
|
||||
func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) {
|
||||
classID, err := uuid.Parse(req.ClassID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid class ID: %w", err)
|
||||
}
|
||||
|
||||
student := &models.Student{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
ClassID: classID,
|
||||
StudentNumber: req.StudentNumber,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Gender: req.Gender,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if req.DateOfBirth != nil {
|
||||
dob, err := time.Parse("2006-01-02", *req.DateOfBirth)
|
||||
if err == nil {
|
||||
student.DateOfBirth = &dob
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`
|
||||
|
||||
err = s.db.Pool.QueryRow(ctx, query,
|
||||
student.ID, student.SchoolID, student.ClassID, student.StudentNumber,
|
||||
student.FirstName, student.LastName, student.DateOfBirth, student.Gender,
|
||||
student.IsActive, student.CreatedAt, student.UpdatedAt,
|
||||
).Scan(&student.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create student: %w", err)
|
||||
}
|
||||
|
||||
return student, nil
|
||||
}
|
||||
|
||||
// GetStudent retrieves a student by ID
|
||||
func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) {
|
||||
query := `
|
||||
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
|
||||
FROM students
|
||||
WHERE id = $1`
|
||||
|
||||
student := &models.Student{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID).Scan(
|
||||
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
|
||||
&student.StudentNumber, &student.FirstName, &student.LastName,
|
||||
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
|
||||
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get student: %w", err)
|
||||
}
|
||||
|
||||
return student, nil
|
||||
}
|
||||
|
||||
// ListStudentsByClass lists all students in a class
|
||||
func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) {
|
||||
query := `
|
||||
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
|
||||
FROM students
|
||||
WHERE class_id = $1 AND is_active = true
|
||||
ORDER BY last_name, first_name`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, classID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list students: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var students []models.Student
|
||||
for rows.Next() {
|
||||
var student models.Student
|
||||
err := rows.Scan(
|
||||
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
|
||||
&student.StudentNumber, &student.FirstName, &student.LastName,
|
||||
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
|
||||
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan student: %w", err)
|
||||
}
|
||||
students = append(students, student)
|
||||
}
|
||||
|
||||
return students, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Teacher Management
|
||||
// ========================================
|
||||
|
||||
// CreateTeacher creates a new teacher linked to a user account
|
||||
func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) {
|
||||
teacher := &models.Teacher{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
UserID: userID,
|
||||
TeacherCode: teacherCode,
|
||||
Title: title,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode,
|
||||
teacher.Title, teacher.FirstName, teacher.LastName,
|
||||
teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt,
|
||||
).Scan(&teacher.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create teacher: %w", err)
|
||||
}
|
||||
|
||||
return teacher, nil
|
||||
}
|
||||
|
||||
// GetTeacher retrieves a teacher by ID
|
||||
func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) {
|
||||
query := `
|
||||
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
|
||||
FROM teachers
|
||||
WHERE id = $1`
|
||||
|
||||
teacher := &models.Teacher{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan(
|
||||
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
|
||||
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
|
||||
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get teacher: %w", err)
|
||||
}
|
||||
|
||||
return teacher, nil
|
||||
}
|
||||
|
||||
// GetTeacherByUserID retrieves a teacher by their user ID
|
||||
func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) {
|
||||
query := `
|
||||
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
|
||||
FROM teachers
|
||||
WHERE user_id = $1 AND is_active = true`
|
||||
|
||||
teacher := &models.Teacher{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
|
||||
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
|
||||
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
|
||||
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get teacher by user ID: %w", err)
|
||||
}
|
||||
|
||||
return teacher, nil
|
||||
}
|
||||
|
||||
// AssignClassTeacher assigns a teacher to a class
|
||||
func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error {
|
||||
query := `
|
||||
INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary`
|
||||
|
||||
_, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to assign class teacher: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Subject Management
|
||||
// ========================================
|
||||
|
||||
// CreateSubject creates a new subject
|
||||
func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) {
|
||||
subject := &models.Subject{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
Color: color,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
subject.ID, subject.SchoolID, subject.Name, subject.ShortName,
|
||||
subject.Color, subject.IsActive, subject.CreatedAt,
|
||||
).Scan(&subject.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create subject: %w", err)
|
||||
}
|
||||
|
||||
return subject, nil
|
||||
}
|
||||
|
||||
// ListSubjects lists all subjects for a school
|
||||
func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) {
|
||||
query := `
|
||||
SELECT id, school_id, name, short_name, color, is_active, created_at
|
||||
FROM subjects
|
||||
WHERE school_id = $1 AND is_active = true
|
||||
ORDER BY name`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, schoolID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list subjects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subjects []models.Subject
|
||||
for rows.Next() {
|
||||
var subject models.Subject
|
||||
err := rows.Scan(
|
||||
&subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName,
|
||||
&subject.Color, &subject.IsActive, &subject.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan subject: %w", err)
|
||||
}
|
||||
subjects = append(subjects, subject)
|
||||
}
|
||||
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Onboarding
|
||||
// ========================================
|
||||
|
||||
// GenerateParentOnboardingToken generates a QR code token for parent onboarding
|
||||
func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) {
|
||||
// Generate secure random token
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
onboardingToken := &models.ParentOnboardingToken{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
ClassID: classID,
|
||||
StudentID: studentID,
|
||||
Token: token,
|
||||
Role: role,
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: createdByUserID,
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID,
|
||||
onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role,
|
||||
onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy,
|
||||
).Scan(&onboardingToken.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create onboarding token: %w", err)
|
||||
}
|
||||
|
||||
return onboardingToken, nil
|
||||
}
|
||||
|
||||
// ValidateOnboardingToken validates and retrieves info for an onboarding token
|
||||
func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) {
|
||||
query := `
|
||||
SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by
|
||||
FROM parent_onboarding_tokens
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`
|
||||
|
||||
onboardingToken := &models.ParentOnboardingToken{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, token).Scan(
|
||||
&onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID,
|
||||
&onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role,
|
||||
&onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID,
|
||||
&onboardingToken.CreatedAt, &onboardingToken.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid or expired token: %w", err)
|
||||
}
|
||||
|
||||
return onboardingToken, nil
|
||||
}
|
||||
|
||||
// RedeemOnboardingToken marks a token as used and creates the parent account
|
||||
func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE parent_onboarding_tokens
|
||||
SET used_at = NOW(), used_by_user_id = $1
|
||||
WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()`
|
||||
|
||||
result, err := s.db.Pool.Exec(ctx, query, userID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to redeem token: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("token not found or already used")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
357
consent-service/internal/services/school_service_members.go
Normal file
357
consent-service/internal/services/school_service_members.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Student Management
|
||||
// ========================================
|
||||
|
||||
// CreateStudent creates a new student
|
||||
func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) {
|
||||
classID, err := uuid.Parse(req.ClassID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid class ID: %w", err)
|
||||
}
|
||||
|
||||
student := &models.Student{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
ClassID: classID,
|
||||
StudentNumber: req.StudentNumber,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Gender: req.Gender,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if req.DateOfBirth != nil {
|
||||
dob, err := time.Parse("2006-01-02", *req.DateOfBirth)
|
||||
if err == nil {
|
||||
student.DateOfBirth = &dob
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`
|
||||
|
||||
err = s.db.Pool.QueryRow(ctx, query,
|
||||
student.ID, student.SchoolID, student.ClassID, student.StudentNumber,
|
||||
student.FirstName, student.LastName, student.DateOfBirth, student.Gender,
|
||||
student.IsActive, student.CreatedAt, student.UpdatedAt,
|
||||
).Scan(&student.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create student: %w", err)
|
||||
}
|
||||
|
||||
return student, nil
|
||||
}
|
||||
|
||||
// GetStudent retrieves a student by ID
|
||||
func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) {
|
||||
query := `
|
||||
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
|
||||
FROM students
|
||||
WHERE id = $1`
|
||||
|
||||
student := &models.Student{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, studentID).Scan(
|
||||
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
|
||||
&student.StudentNumber, &student.FirstName, &student.LastName,
|
||||
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
|
||||
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get student: %w", err)
|
||||
}
|
||||
|
||||
return student, nil
|
||||
}
|
||||
|
||||
// ListStudentsByClass lists all students in a class
|
||||
func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) {
|
||||
query := `
|
||||
SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at
|
||||
FROM students
|
||||
WHERE class_id = $1 AND is_active = true
|
||||
ORDER BY last_name, first_name`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, classID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list students: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var students []models.Student
|
||||
for rows.Next() {
|
||||
var student models.Student
|
||||
err := rows.Scan(
|
||||
&student.ID, &student.SchoolID, &student.ClassID, &student.UserID,
|
||||
&student.StudentNumber, &student.FirstName, &student.LastName,
|
||||
&student.DateOfBirth, &student.Gender, &student.MatrixUserID,
|
||||
&student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan student: %w", err)
|
||||
}
|
||||
students = append(students, student)
|
||||
}
|
||||
|
||||
return students, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Teacher Management
|
||||
// ========================================
|
||||
|
||||
// CreateTeacher creates a new teacher linked to a user account
|
||||
func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) {
|
||||
teacher := &models.Teacher{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
UserID: userID,
|
||||
TeacherCode: teacherCode,
|
||||
Title: title,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode,
|
||||
teacher.Title, teacher.FirstName, teacher.LastName,
|
||||
teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt,
|
||||
).Scan(&teacher.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create teacher: %w", err)
|
||||
}
|
||||
|
||||
return teacher, nil
|
||||
}
|
||||
|
||||
// GetTeacher retrieves a teacher by ID
|
||||
func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) {
|
||||
query := `
|
||||
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
|
||||
FROM teachers
|
||||
WHERE id = $1`
|
||||
|
||||
teacher := &models.Teacher{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan(
|
||||
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
|
||||
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
|
||||
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get teacher: %w", err)
|
||||
}
|
||||
|
||||
return teacher, nil
|
||||
}
|
||||
|
||||
// GetTeacherByUserID retrieves a teacher by their user ID
|
||||
func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) {
|
||||
query := `
|
||||
SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at
|
||||
FROM teachers
|
||||
WHERE user_id = $1 AND is_active = true`
|
||||
|
||||
teacher := &models.Teacher{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, userID).Scan(
|
||||
&teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode,
|
||||
&teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID,
|
||||
&teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get teacher by user ID: %w", err)
|
||||
}
|
||||
|
||||
return teacher, nil
|
||||
}
|
||||
|
||||
// AssignClassTeacher assigns a teacher to a class
|
||||
func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error {
|
||||
query := `
|
||||
INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary`
|
||||
|
||||
_, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to assign class teacher: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Subject Management
|
||||
// ========================================
|
||||
|
||||
// CreateSubject creates a new subject
|
||||
func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) {
|
||||
subject := &models.Subject{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
Color: color,
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
subject.ID, subject.SchoolID, subject.Name, subject.ShortName,
|
||||
subject.Color, subject.IsActive, subject.CreatedAt,
|
||||
).Scan(&subject.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create subject: %w", err)
|
||||
}
|
||||
|
||||
return subject, nil
|
||||
}
|
||||
|
||||
// ListSubjects lists all subjects for a school
|
||||
func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) {
|
||||
query := `
|
||||
SELECT id, school_id, name, short_name, color, is_active, created_at
|
||||
FROM subjects
|
||||
WHERE school_id = $1 AND is_active = true
|
||||
ORDER BY name`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, schoolID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list subjects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subjects []models.Subject
|
||||
for rows.Next() {
|
||||
var subject models.Subject
|
||||
err := rows.Scan(
|
||||
&subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName,
|
||||
&subject.Color, &subject.IsActive, &subject.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan subject: %w", err)
|
||||
}
|
||||
subjects = append(subjects, subject)
|
||||
}
|
||||
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parent Onboarding
|
||||
// ========================================
|
||||
|
||||
// GenerateParentOnboardingToken generates a QR code token for parent onboarding
|
||||
func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) {
|
||||
// Generate secure random token
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
onboardingToken := &models.ParentOnboardingToken{
|
||||
ID: uuid.New(),
|
||||
SchoolID: schoolID,
|
||||
ClassID: classID,
|
||||
StudentID: studentID,
|
||||
Token: token,
|
||||
Role: role,
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: createdByUserID,
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, query,
|
||||
onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID,
|
||||
onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role,
|
||||
onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy,
|
||||
).Scan(&onboardingToken.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create onboarding token: %w", err)
|
||||
}
|
||||
|
||||
return onboardingToken, nil
|
||||
}
|
||||
|
||||
// ValidateOnboardingToken validates and retrieves info for an onboarding token
|
||||
func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) {
|
||||
query := `
|
||||
SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by
|
||||
FROM parent_onboarding_tokens
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`
|
||||
|
||||
onboardingToken := &models.ParentOnboardingToken{}
|
||||
err := s.db.Pool.QueryRow(ctx, query, token).Scan(
|
||||
&onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID,
|
||||
&onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role,
|
||||
&onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID,
|
||||
&onboardingToken.CreatedAt, &onboardingToken.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid or expired token: %w", err)
|
||||
}
|
||||
|
||||
return onboardingToken, nil
|
||||
}
|
||||
|
||||
// RedeemOnboardingToken marks a token as used and creates the parent account
|
||||
func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE parent_onboarding_tokens
|
||||
SET used_at = NOW(), used_by_user_id = $1
|
||||
WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()`
|
||||
|
||||
result, err := s.db.Pool.Exec(ctx, query, userID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to redeem token: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("token not found or already used")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user