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:
347
consent-service/internal/services/notification_service.go
Normal file
347
consent-service/internal/services/notification_service.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NotificationType defines the type of notification
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationTypeConsentRequired NotificationType = "consent_required"
|
||||
NotificationTypeConsentReminder NotificationType = "consent_reminder"
|
||||
NotificationTypeVersionPublished NotificationType = "version_published"
|
||||
NotificationTypeVersionApproved NotificationType = "version_approved"
|
||||
NotificationTypeVersionRejected NotificationType = "version_rejected"
|
||||
NotificationTypeAccountSuspended NotificationType = "account_suspended"
|
||||
NotificationTypeAccountRestored NotificationType = "account_restored"
|
||||
NotificationTypeGeneral NotificationType = "general"
|
||||
// DSR (Data Subject Request) notification types
|
||||
NotificationTypeDSRReceived NotificationType = "dsr_received"
|
||||
NotificationTypeDSRAssigned NotificationType = "dsr_assigned"
|
||||
NotificationTypeDSRDeadline NotificationType = "dsr_deadline"
|
||||
)
|
||||
|
||||
// NotificationChannel defines how notification is delivered
|
||||
type NotificationChannel string
|
||||
|
||||
const (
|
||||
ChannelInApp NotificationChannel = "in_app"
|
||||
ChannelEmail NotificationChannel = "email"
|
||||
ChannelPush NotificationChannel = "push"
|
||||
)
|
||||
|
||||
// Notification represents a notification entity
|
||||
type Notification struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Type NotificationType `json:"type"`
|
||||
Channel NotificationChannel `json:"channel"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NotificationPreferences holds user notification settings
|
||||
type NotificationPreferences struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
PushEnabled bool `json:"push_enabled"`
|
||||
InAppEnabled bool `json:"in_app_enabled"`
|
||||
ReminderFrequency string `json:"reminder_frequency"`
|
||||
}
|
||||
|
||||
// NotificationService handles notification operations
|
||||
type NotificationService struct {
|
||||
pool *pgxpool.Pool
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service
|
||||
func NewNotificationService(pool *pgxpool.Pool, emailService *EmailService) *NotificationService {
|
||||
return &NotificationService{
|
||||
pool: pool,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNotification creates and optionally sends a notification
|
||||
func (s *NotificationService) CreateNotification(ctx context.Context, userID uuid.UUID, notifType NotificationType, title, body string, data map[string]interface{}) error {
|
||||
// Get user preferences
|
||||
prefs, err := s.GetPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
// Use default preferences if not found
|
||||
prefs = &NotificationPreferences{
|
||||
UserID: userID,
|
||||
EmailEnabled: true,
|
||||
PushEnabled: true,
|
||||
InAppEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Create in-app notification if enabled
|
||||
if prefs.InAppEnabled {
|
||||
if err := s.createInAppNotification(ctx, userID, notifType, title, body, data); err != nil {
|
||||
return fmt.Errorf("failed to create in-app notification: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send email notification if enabled
|
||||
if prefs.EmailEnabled && s.emailService != nil {
|
||||
go s.sendEmailNotification(ctx, userID, notifType, title, body, data)
|
||||
}
|
||||
|
||||
// Push notification would be sent here if enabled
|
||||
// if prefs.PushEnabled {
|
||||
// go s.sendPushNotification(ctx, userID, title, body, data)
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createInAppNotification creates an in-app notification
|
||||
func (s *NotificationService) createInAppNotification(ctx context.Context, userID uuid.UUID, notifType NotificationType, title, body string, data map[string]interface{}) error {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO notifications (user_id, type, channel, title, body, data, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
`, userID, notifType, ChannelInApp, title, body, dataJSON)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// sendEmailNotification sends an email notification
|
||||
func (s *NotificationService) sendEmailNotification(ctx context.Context, userID uuid.UUID, notifType NotificationType, title, body string, data map[string]interface{}) {
|
||||
// Get user email
|
||||
var email string
|
||||
err := s.pool.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Send based on notification type
|
||||
switch notifType {
|
||||
case NotificationTypeConsentRequired, NotificationTypeConsentReminder:
|
||||
s.emailService.SendConsentReminderEmail(email, title, body)
|
||||
default:
|
||||
s.emailService.SendGenericNotificationEmail(email, title, body)
|
||||
}
|
||||
|
||||
// Mark as sent
|
||||
s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET sent_at = NOW()
|
||||
WHERE user_id = $1 AND type = $2 AND channel = $3 AND sent_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, userID, notifType, ChannelEmail)
|
||||
}
|
||||
|
||||
// GetUserNotifications returns notifications for a user
|
||||
func (s *NotificationService) GetUserNotifications(ctx context.Context, userID uuid.UUID, limit, offset int, unreadOnly bool) ([]Notification, int, error) {
|
||||
// Count total
|
||||
var totalQuery string
|
||||
var total int
|
||||
|
||||
if unreadOnly {
|
||||
totalQuery = `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`
|
||||
} else {
|
||||
totalQuery = `SELECT COUNT(*) FROM notifications WHERE user_id = $1`
|
||||
}
|
||||
s.pool.QueryRow(ctx, totalQuery, userID).Scan(&total)
|
||||
|
||||
// Get notifications
|
||||
var query string
|
||||
if unreadOnly {
|
||||
query = `
|
||||
SELECT id, user_id, type, channel, title, body, data, read_at, sent_at, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = $1 AND read_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, user_id, type, channel, title, body, data, read_at, sent_at, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notifications []Notification
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var dataJSON []byte
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Channel, &n.Title, &n.Body, &dataJSON, &n.ReadAt, &n.SentAt, &n.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if dataJSON != nil {
|
||||
json.Unmarshal(dataJSON, &n.Data)
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
|
||||
return notifications, total, nil
|
||||
}
|
||||
|
||||
// GetUnreadCount returns the count of unread notifications
|
||||
func (s *NotificationService) GetUnreadCount(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL
|
||||
`, userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// MarkAsRead marks a notification as read
|
||||
func (s *NotificationService) MarkAsRead(ctx context.Context, userID uuid.UUID, notificationID uuid.UUID) error {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET read_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND read_at IS NULL
|
||||
`, notificationID, userID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("notification not found or already read")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read for a user
|
||||
func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE notifications SET read_at = NOW()
|
||||
WHERE user_id = $1 AND read_at IS NULL
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNotification deletes a notification
|
||||
func (s *NotificationService) DeleteNotification(ctx context.Context, userID uuid.UUID, notificationID uuid.UUID) error {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM notifications WHERE id = $1 AND user_id = $2
|
||||
`, notificationID, userID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("notification not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPreferences returns notification preferences for a user
|
||||
func (s *NotificationService) GetPreferences(ctx context.Context, userID uuid.UUID) (*NotificationPreferences, error) {
|
||||
var prefs NotificationPreferences
|
||||
prefs.UserID = userID
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT email_enabled, push_enabled, in_app_enabled, reminder_frequency
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&prefs.EmailEnabled, &prefs.PushEnabled, &prefs.InAppEnabled, &prefs.ReminderFrequency)
|
||||
|
||||
if err != nil {
|
||||
// Return defaults if not found
|
||||
return &NotificationPreferences{
|
||||
UserID: userID,
|
||||
EmailEnabled: true,
|
||||
PushEnabled: true,
|
||||
InAppEnabled: true,
|
||||
ReminderFrequency: "weekly",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &prefs, nil
|
||||
}
|
||||
|
||||
// UpdatePreferences updates notification preferences for a user
|
||||
func (s *NotificationService) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *NotificationPreferences) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO notification_preferences (user_id, email_enabled, push_enabled, in_app_enabled, reminder_frequency, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email_enabled = $2,
|
||||
push_enabled = $3,
|
||||
in_app_enabled = $4,
|
||||
reminder_frequency = $5,
|
||||
updated_at = NOW()
|
||||
`, userID, prefs.EmailEnabled, prefs.PushEnabled, prefs.InAppEnabled, prefs.ReminderFrequency)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// NotifyConsentRequired sends consent required notifications to all active users
|
||||
func (s *NotificationService) NotifyConsentRequired(ctx context.Context, documentName, versionID string) error {
|
||||
// Get all active users
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id FROM users WHERE account_status = 'active'
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
title := "Neue Zustimmung erforderlich"
|
||||
body := fmt.Sprintf("Eine neue Version von '%s' wurde veröffentlicht. Bitte überprüfen und bestätigen Sie diese.", documentName)
|
||||
data := map[string]interface{}{
|
||||
"version_id": versionID,
|
||||
"document_name": documentName,
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
continue
|
||||
}
|
||||
go s.CreateNotification(ctx, userID, NotificationTypeConsentRequired, title, body, data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyVersionApproved notifies the creator that their version was approved
|
||||
func (s *NotificationService) NotifyVersionApproved(ctx context.Context, creatorID uuid.UUID, documentName, versionNumber, approverEmail string) error {
|
||||
title := "Version genehmigt"
|
||||
body := fmt.Sprintf("Ihre Version %s von '%s' wurde von %s genehmigt und kann nun veröffentlicht werden.", versionNumber, documentName, approverEmail)
|
||||
data := map[string]interface{}{
|
||||
"document_name": documentName,
|
||||
"version_number": versionNumber,
|
||||
"approver": approverEmail,
|
||||
}
|
||||
|
||||
return s.CreateNotification(ctx, creatorID, NotificationTypeVersionApproved, title, body, data)
|
||||
}
|
||||
|
||||
// NotifyVersionRejected notifies the creator that their version was rejected
|
||||
func (s *NotificationService) NotifyVersionRejected(ctx context.Context, creatorID uuid.UUID, documentName, versionNumber, reason, rejecterEmail string) error {
|
||||
title := "Version abgelehnt"
|
||||
body := fmt.Sprintf("Ihre Version %s von '%s' wurde von %s abgelehnt. Grund: %s", versionNumber, documentName, rejecterEmail, reason)
|
||||
data := map[string]interface{}{
|
||||
"document_name": documentName,
|
||||
"version_number": versionNumber,
|
||||
"rejecter": rejecterEmail,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
return s.CreateNotification(ctx, creatorID, NotificationTypeVersionRejected, title, body, data)
|
||||
}
|
||||
Reference in New Issue
Block a user