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>
This commit is contained in:
434
consent-service/internal/services/deadline_service.go
Normal file
434
consent-service/internal/services/deadline_service.go
Normal file
@@ -0,0 +1,434 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user