package services import ( "context" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // DeadlineService handles consent deadlines and account suspensions type DeadlineService struct { pool *pgxpool.Pool notificationService *NotificationService } // ConsentDeadline represents a consent deadline for a user type ConsentDeadline struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` DocumentVersionID uuid.UUID `json:"document_version_id"` DeadlineAt time.Time `json:"deadline_at"` ReminderCount int `json:"reminder_count"` LastReminderAt *time.Time `json:"last_reminder_at"` ConsentGivenAt *time.Time `json:"consent_given_at"` CreatedAt time.Time `json:"created_at"` // Joined fields DocumentName string `json:"document_name"` VersionNumber string `json:"version_number"` } // AccountSuspension represents an account suspension type AccountSuspension struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` Reason string `json:"reason"` Details map[string]interface{} `json:"details"` SuspendedAt time.Time `json:"suspended_at"` LiftedAt *time.Time `json:"lifted_at"` LiftedBy *uuid.UUID `json:"lifted_by"` } // NewDeadlineService creates a new deadline service func NewDeadlineService(pool *pgxpool.Pool, notificationService *NotificationService) *DeadlineService { return &DeadlineService{ pool: pool, notificationService: notificationService, } } // CreateDeadlinesForPublishedVersion creates consent deadlines for all active users // when a new mandatory document version is published func (s *DeadlineService) CreateDeadlinesForPublishedVersion(ctx context.Context, versionID uuid.UUID) error { // Get version info var documentName, versionNumber string var isMandatory bool err := s.pool.QueryRow(ctx, ` SELECT ld.name, dv.version, ld.is_mandatory FROM document_versions dv JOIN legal_documents ld ON dv.document_id = ld.id WHERE dv.id = $1 `, versionID).Scan(&documentName, &versionNumber, &isMandatory) if err != nil { return fmt.Errorf("failed to get version info: %w", err) } // Only create deadlines for mandatory documents if !isMandatory { return nil } // Deadline is 30 days from now deadlineAt := time.Now().AddDate(0, 0, 30) // Get all active users who haven't given consent to this version _, err = s.pool.Exec(ctx, ` INSERT INTO consent_deadlines (user_id, document_version_id, deadline_at) SELECT u.id, $1, $2 FROM users u WHERE u.account_status = 'active' AND NOT EXISTS ( SELECT 1 FROM user_consents uc WHERE uc.user_id = u.id AND uc.document_version_id = $1 AND uc.consented = TRUE ) ON CONFLICT (user_id, document_version_id) DO NOTHING `, versionID, deadlineAt) if err != nil { return fmt.Errorf("failed to create deadlines: %w", err) } // Notify users via notification service if s.notificationService != nil { go s.notificationService.NotifyConsentRequired(ctx, documentName, versionID.String()) } return nil } // MarkConsentGiven marks a deadline as fulfilled when user gives consent func (s *DeadlineService) MarkConsentGiven(ctx context.Context, userID, versionID uuid.UUID) error { _, err := s.pool.Exec(ctx, ` UPDATE consent_deadlines SET consent_given_at = NOW() WHERE user_id = $1 AND document_version_id = $2 AND consent_given_at IS NULL `, userID, versionID) if err != nil { return err } // Check if user should be unsuspended return s.checkAndLiftSuspension(ctx, userID) } // GetPendingDeadlines returns all pending deadlines for a user func (s *DeadlineService) GetPendingDeadlines(ctx context.Context, userID uuid.UUID) ([]ConsentDeadline, error) { rows, err := s.pool.Query(ctx, ` SELECT cd.id, cd.user_id, cd.document_version_id, cd.deadline_at, cd.reminder_count, cd.last_reminder_at, cd.consent_given_at, cd.created_at, ld.name as document_name, dv.version as version_number FROM consent_deadlines cd JOIN document_versions dv ON cd.document_version_id = dv.id JOIN legal_documents ld ON dv.document_id = ld.id WHERE cd.user_id = $1 AND cd.consent_given_at IS NULL ORDER BY cd.deadline_at ASC `, userID) if err != nil { return nil, err } defer rows.Close() var deadlines []ConsentDeadline for rows.Next() { var d ConsentDeadline if err := rows.Scan(&d.ID, &d.UserID, &d.DocumentVersionID, &d.DeadlineAt, &d.ReminderCount, &d.LastReminderAt, &d.ConsentGivenAt, &d.CreatedAt, &d.DocumentName, &d.VersionNumber); err != nil { continue } deadlines = append(deadlines, d) } return deadlines, nil } // ProcessDailyDeadlines is meant to be called by a cron job daily // It sends reminders and suspends accounts that have missed deadlines func (s *DeadlineService) ProcessDailyDeadlines(ctx context.Context) error { now := time.Now() // 1. Send reminders for upcoming deadlines if err := s.sendReminders(ctx, now); err != nil { fmt.Printf("Error sending reminders: %v\n", err) } // 2. Suspend accounts with expired deadlines if err := s.suspendExpiredAccounts(ctx, now); err != nil { fmt.Printf("Error suspending accounts: %v\n", err) } return nil } // sendReminders sends reminder notifications based on days remaining func (s *DeadlineService) sendReminders(ctx context.Context, now time.Time) error { // Reminder schedule: Day 7, 14, 21, 28 reminderDays := []int{7, 14, 21, 28} for _, days := range reminderDays { targetDate := now.AddDate(0, 0, days) dayStart := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, targetDate.Location()) dayEnd := dayStart.AddDate(0, 0, 1) // Find deadlines that fall on this reminder day rows, err := s.pool.Query(ctx, ` SELECT cd.id, cd.user_id, cd.document_version_id, cd.deadline_at, cd.reminder_count, ld.name as document_name FROM consent_deadlines cd JOIN document_versions dv ON cd.document_version_id = dv.id JOIN legal_documents ld ON dv.document_id = ld.id WHERE cd.consent_given_at IS NULL AND cd.deadline_at >= $1 AND cd.deadline_at < $2 AND (cd.last_reminder_at IS NULL OR cd.last_reminder_at < $3) `, dayStart, dayEnd, dayStart) if err != nil { continue } for rows.Next() { var id, userID, versionID uuid.UUID var deadlineAt time.Time var reminderCount int var documentName string if err := rows.Scan(&id, &userID, &versionID, &deadlineAt, &reminderCount, &documentName); err != nil { continue } // Send reminder notification daysLeft := 30 - (30 - days) urgency := "freundlich" if days <= 7 { urgency = "dringend" } else if days <= 14 { urgency = "wichtig" } title := fmt.Sprintf("Erinnerung: Zustimmung erforderlich (%s)", urgency) body := fmt.Sprintf("Bitte bestätigen Sie '%s' innerhalb von %d Tagen.", documentName, daysLeft) if s.notificationService != nil { s.notificationService.CreateNotification(ctx, userID, NotificationTypeConsentReminder, title, body, map[string]interface{}{ "document_name": documentName, "days_left": daysLeft, "version_id": versionID.String(), }) } // Update reminder count and timestamp s.pool.Exec(ctx, ` UPDATE consent_deadlines SET reminder_count = reminder_count + 1, last_reminder_at = NOW() WHERE id = $1 `, id) } rows.Close() } return nil } // suspendExpiredAccounts suspends accounts that have missed their deadline func (s *DeadlineService) suspendExpiredAccounts(ctx context.Context, now time.Time) error { // Find users with expired deadlines rows, err := s.pool.Query(ctx, ` SELECT DISTINCT cd.user_id, array_agg(ld.name) as documents FROM consent_deadlines cd JOIN document_versions dv ON cd.document_version_id = dv.id JOIN legal_documents ld ON dv.document_id = ld.id JOIN users u ON cd.user_id = u.id WHERE cd.consent_given_at IS NULL AND cd.deadline_at < $1 AND u.account_status = 'active' AND ld.is_mandatory = TRUE GROUP BY cd.user_id `, now) if err != nil { return err } defer rows.Close() for rows.Next() { var userID uuid.UUID var documents []string if err := rows.Scan(&userID, &documents); err != nil { continue } // Suspend the account if err := s.suspendAccount(ctx, userID, "consent_deadline_missed", documents); err != nil { fmt.Printf("Failed to suspend user %s: %v\n", userID, err) } } return nil } // suspendAccount suspends a user account func (s *DeadlineService) suspendAccount(ctx context.Context, userID uuid.UUID, reason string, documents []string) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) // Update user status _, err = tx.Exec(ctx, ` UPDATE users SET account_status = 'suspended', updated_at = NOW() WHERE id = $1 AND account_status = 'active' `, userID) if err != nil { return err } // Create suspension record _, err = tx.Exec(ctx, ` INSERT INTO account_suspensions (user_id, reason, details) VALUES ($1, $2, $3) `, userID, reason, map[string]interface{}{"documents": documents}) if err != nil { return err } // Log to audit _, err = tx.Exec(ctx, ` INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id, details) VALUES ($1, 'account_suspended', 'user', $1, $2) `, userID, map[string]interface{}{"reason": reason, "documents": documents}) if err != nil { return err } if err := tx.Commit(ctx); err != nil { return err } // Send suspension notification if s.notificationService != nil { title := "Account vorübergehend gesperrt" body := "Ihr Account wurde gesperrt, da ausstehende Zustimmungen nicht innerhalb der Frist erteilt wurden. Bitte bestätigen Sie die ausstehenden Dokumente." s.notificationService.CreateNotification(ctx, userID, NotificationTypeAccountSuspended, title, body, map[string]interface{}{ "documents": documents, }) } return nil } // checkAndLiftSuspension checks if user has completed all required consents and lifts suspension func (s *DeadlineService) checkAndLiftSuspension(ctx context.Context, userID uuid.UUID) error { // Check if user is currently suspended var accountStatus string err := s.pool.QueryRow(ctx, `SELECT account_status FROM users WHERE id = $1`, userID).Scan(&accountStatus) if err != nil || accountStatus != "suspended" { return nil } // Check if there are any pending mandatory consents var pendingCount int err = s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM consent_deadlines cd JOIN document_versions dv ON cd.document_version_id = dv.id JOIN legal_documents ld ON dv.document_id = ld.id WHERE cd.user_id = $1 AND cd.consent_given_at IS NULL AND ld.is_mandatory = TRUE `, userID).Scan(&pendingCount) if err != nil { return err } // If no pending consents, lift the suspension if pendingCount == 0 { return s.liftSuspension(ctx, userID) } return nil } // liftSuspension lifts a user's suspension func (s *DeadlineService) liftSuspension(ctx context.Context, userID uuid.UUID) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) // Update user status _, err = tx.Exec(ctx, ` UPDATE users SET account_status = 'active', updated_at = NOW() WHERE id = $1 AND account_status = 'suspended' `, userID) if err != nil { return err } // Update suspension record _, err = tx.Exec(ctx, ` UPDATE account_suspensions SET lifted_at = NOW() WHERE user_id = $1 AND lifted_at IS NULL `, userID) if err != nil { return err } // Log to audit _, err = tx.Exec(ctx, ` INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id) VALUES ($1, 'account_restored', 'user', $1) `, userID) if err != nil { return err } if err := tx.Commit(ctx); err != nil { return err } // Send restoration notification if s.notificationService != nil { title := "Account wiederhergestellt" body := "Vielen Dank! Ihr Account wurde wiederhergestellt. Sie können die Anwendung wieder vollständig nutzen." s.notificationService.CreateNotification(ctx, userID, NotificationTypeAccountRestored, title, body, nil) } return nil } // GetAccountSuspension returns the current suspension for a user func (s *DeadlineService) GetAccountSuspension(ctx context.Context, userID uuid.UUID) (*AccountSuspension, error) { var suspension AccountSuspension err := s.pool.QueryRow(ctx, ` SELECT id, user_id, reason, details, suspended_at, lifted_at, lifted_by FROM account_suspensions WHERE user_id = $1 AND lifted_at IS NULL ORDER BY suspended_at DESC LIMIT 1 `, userID).Scan(&suspension.ID, &suspension.UserID, &suspension.Reason, &suspension.Details, &suspension.SuspendedAt, &suspension.LiftedAt, &suspension.LiftedBy) if err != nil { return nil, err } return &suspension, nil } // IsUserSuspended checks if a user is currently suspended func (s *DeadlineService) IsUserSuspended(ctx context.Context, userID uuid.UUID) (bool, error) { var status string err := s.pool.QueryRow(ctx, `SELECT account_status FROM users WHERE id = $1`, userID).Scan(&status) if err != nil { return false, err } return status == "suspended", nil }