Files
breakpilot-core/consent-service/internal/services/deadline_service.go
Benjamin Boenisch ad111d5e69 Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services:
- PostgreSQL (PostGIS), Valkey, MinIO, Qdrant
- Vault (PKI/TLS), Nginx (Reverse Proxy)
- Backend Core API, Consent Service, Billing Service
- RAG Service, Embedding Service
- Gitea, Woodpecker CI/CD
- Night Scheduler, Health Aggregator
- Jitsi (Web/XMPP/JVB/Jicofo), Mailpit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:13 +01:00

435 lines
13 KiB
Go

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
}