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>
435 lines
13 KiB
Go
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
|
|
}
|