veza/veza-backend-api/internal/services/notification_service.go

479 lines
15 KiB
Go

package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/database"
ws "veza-backend-api/internal/websocket"
"go.uber.org/zap"
)
// NotificationWSNotifier sends notification payloads to users via WebSocket (v0.10.5 F551)
type NotificationWSNotifier interface {
NotifyUser(userID uuid.UUID, payload []byte)
}
// NotificationService handles notification operations
type NotificationService struct {
db *database.Database
logger *zap.Logger
pushService *PushService // optional, for N1.2 Web Push
wsNotifier NotificationWSNotifier // optional, for F551 real-time
}
// SetWSNotifier injects the WebSocket notifier for real-time delivery (v0.10.5 F551)
func (ns *NotificationService) SetWSNotifier(n NotificationWSNotifier) {
ns.wsNotifier = n
}
// Notification represents a notification (F554: grouping fields)
type Notification struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Type string `json:"type" db:"type"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
Link string `json:"link" db:"link"`
Read bool `json:"read" db:"read"`
CreatedAt string `json:"created_at" db:"created_at"`
GroupKey string `json:"group_key,omitempty" db:"group_key"`
ActorCount int `json:"actor_count" db:"actor_count"`
Metadata string `json:"metadata,omitempty" db:"metadata"` // JSON string
}
// NewNotificationService creates a new notification service
func NewNotificationService(db *database.Database, logger *zap.Logger) *NotificationService {
return &NotificationService{
db: db,
logger: logger,
}
}
// SetPushService injects the push service for Web Push (N1.2)
func (ns *NotificationService) SetPushService(ps *PushService) {
ns.pushService = ps
}
// timeHHMM matches "HH:MM" or "HH:MM:SS"
var timeHHMM = regexp.MustCompile(`^(\d{1,2}):(\d{2})(?::(\d{2}))?$`)
// isWithinQuietHours returns true if current time (UTC) falls within quiet hours (F553)
// Supports overnight ranges: start 22:00, end 08:00 means 10pm-8am
func (ns *NotificationService) isWithinQuietHours(prefs *NotificationPrefs) bool {
if !prefs.QuietHoursEnabled || prefs.QuietHoursStart == "" || prefs.QuietHoursEnd == "" {
return false
}
now := time.Now().UTC()
startMin := parseTimeToMinutes(prefs.QuietHoursStart)
endMin := parseTimeToMinutes(prefs.QuietHoursEnd)
if startMin < 0 || endMin < 0 {
return false
}
nowMin := now.Hour()*60 + now.Minute()
if startMin <= endMin {
return nowMin >= startMin && nowMin < endMin
}
// Overnight: e.g. 22:00-08:00
return nowMin >= startMin || nowMin < endMin
}
func parseTimeToMinutes(s string) int {
m := timeHHMM.FindStringSubmatch(s)
if m == nil {
return -1
}
var h, min int
_, _ = fmt.Sscanf(m[1], "%d", &h)
_, _ = fmt.Sscanf(m[2], "%d", &min)
if h < 0 || h > 23 || min < 0 || min > 59 {
return -1
}
return h*60 + min
}
// CreateNotification creates a new notification and optionally sends Web Push (N1.2)
// groupKey and actorID are optional (F554): when set, may update existing recent notification instead of insert
func (ns *NotificationService) CreateNotification(userID uuid.UUID, notificationType, title, content, link string) error {
return ns.CreateNotificationWithGroup(userID, notificationType, title, content, link, "", uuid.Nil)
}
// CreateNotificationWithGroup supports grouping (F554): when groupKey is set, checks for existing
// notification with same group_key created < 24h ago; if found, increments actor_count and updates metadata
func (ns *NotificationService) CreateNotificationWithGroup(userID uuid.UUID, notificationType, title, content, link, groupKey string, actorID uuid.UUID) error {
ctx := context.Background()
if groupKey != "" && actorID != uuid.Nil {
var existingID uuid.UUID
var actorCount int
var metadata sql.NullString
err := ns.db.QueryRowContext(ctx, `
SELECT id, COALESCE(actor_count, 1), COALESCE(metadata::text, '{}')
FROM notifications
WHERE user_id = $1 AND group_key = $2 AND created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 1
`, userID, groupKey).Scan(&existingID, &actorCount, &metadata)
if err == nil {
newCount := actorCount + 1
metadataJSON := "[]"
if metadata.Valid && metadata.String != "" && metadata.String != "{}" {
metadataJSON = metadata.String
}
actorIDs := []string{}
_ = json.Unmarshal([]byte(metadataJSON), &actorIDs)
actorIDs = append(actorIDs, actorID.String())
actorIDsJSON, _ := json.Marshal(actorIDs)
_, err = ns.db.ExecContext(ctx, `
UPDATE notifications SET
actor_count = $1, metadata = $2::jsonb, updated_at = NOW()
WHERE id = $3
`, newCount, string(actorIDsJSON), existingID)
if err == nil {
return nil
}
}
}
actorCount := 1
metadata := "{}"
if actorID != uuid.Nil {
actorIDsJSON, _ := json.Marshal([]string{actorID.String()})
metadata = string(actorIDsJSON)
}
_, err := ns.db.ExecContext(ctx, `
INSERT INTO notifications (user_id, type, title, content, link, group_key, actor_count, metadata)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), $7, $8::jsonb)
`, userID, notificationType, title, content, link, groupKey, actorCount, metadata)
if err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
// F553: Check quiet hours before push/WS delivery
prefs, prefsErr := ns.GetPreferences(userID)
if prefsErr != nil {
ns.logger.Warn("failed to get preferences for quiet hours", zap.Error(prefsErr))
}
withinQuietHours := prefs != nil && ns.isWithinQuietHours(prefs)
// F551: Send real-time via WebSocket (in-app) — skip during quiet hours
if !withinQuietHours && ns.wsNotifier != nil {
msg := ws.NewWebSocketMessage(ws.MessageTypeNotification, map[string]interface{}{
"type": notificationType,
"title": title,
"content": content,
"link": link,
})
if payload, err := json.Marshal(msg); err == nil {
ns.wsNotifier.NotifyUser(userID, payload)
} else {
ns.logger.Warn("failed to marshal notification WS message", zap.Error(err))
}
}
// N1.2: Send Web Push if enabled and user has subscriptions — skip during quiet hours
if !withinQuietHours && ns.pushService != nil && prefs != nil {
var shouldPush bool
switch notificationType {
case "follow":
shouldPush = prefs.PushFollow
case "like":
shouldPush = prefs.PushLike
case "comment":
shouldPush = prefs.PushComment
case "new_message", "message":
shouldPush = prefs.PushMessage
case "user_mentioned", "mention":
shouldPush = prefs.PushMention
default:
shouldPush = false
}
if shouldPush {
if err := ns.pushService.SendPushToUser(ctx, userID, title, content, link); err != nil {
ns.logger.Warn("failed to send push notification", zap.Error(err), zap.String("user_id", userID.String()))
}
}
}
return nil
}
// GetNotificationsParams holds pagination and filter params (v0.10.5 F555)
type GetNotificationsParams struct {
UnreadOnly bool
TypeFilter string // empty = all
Page int // 1-based
Limit int // default 20, max 100
}
// GetNotificationsResult holds paginated result (v0.10.5 F555)
type GetNotificationsResult struct {
Notifications []Notification
Total int
Page int
Limit int
TotalPages int
UnreadCount int
}
// GetNotifications retrieves notifications for a user with pagination and filters
func (ns *NotificationService) GetNotifications(userID uuid.UUID, params GetNotificationsParams) (*GetNotificationsResult, error) {
ctx := context.Background()
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.Page < 1 {
params.Page = 1
}
offset := (params.Page - 1) * params.Limit
where := "WHERE user_id = $1"
whereArgs := []interface{}{userID}
argIdx := 2
if params.UnreadOnly {
where += " AND read = FALSE"
}
if params.TypeFilter != "" {
where += fmt.Sprintf(" AND type = $%d", argIdx)
whereArgs = append(whereArgs, params.TypeFilter)
argIdx++
}
countQuery := "SELECT COUNT(*) FROM notifications " + where
var total int
if err := ns.db.QueryRowContext(ctx, countQuery, whereArgs...).Scan(&total); err != nil {
return nil, fmt.Errorf("failed to count notifications: %w", err)
}
unreadCount := 0
if err := ns.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read = FALSE
`, userID).Scan(&unreadCount); err != nil {
unreadCount = 0
}
selectQuery := fmt.Sprintf(`
SELECT id, user_id, type, title, content, link, read, created_at
FROM notifications %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, where, argIdx, argIdx+1)
selectArgs := append(whereArgs, params.Limit, offset)
rows, err := ns.db.QueryContext(ctx, selectQuery, selectArgs...)
if err != nil {
return nil, fmt.Errorf("failed to get notifications: %w", err)
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
var notification Notification
if err := rows.Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Content,
&notification.Link,
&notification.Read,
&notification.CreatedAt,
); err != nil {
continue
}
notifications = append(notifications, notification)
}
totalPages := (total + params.Limit - 1) / params.Limit
if totalPages < 1 {
totalPages = 1
}
return &GetNotificationsResult{
Notifications: notifications,
Total: total,
Page: params.Page,
Limit: params.Limit,
TotalPages: totalPages,
UnreadCount: unreadCount,
}, nil
}
// MarkAsRead marks a notification as read
func (ns *NotificationService) MarkAsRead(userID uuid.UUID, notificationID uuid.UUID) error {
ctx := context.Background()
_, err := ns.db.ExecContext(ctx, `
UPDATE notifications
SET read = TRUE
WHERE id = $1 AND user_id = $2
`, notificationID, userID)
if err != nil {
return fmt.Errorf("failed to mark notification as read: %w", err)
}
return nil
}
// MarkAllAsRead marks all notifications as read for a user
func (ns *NotificationService) MarkAllAsRead(userID uuid.UUID) error {
ctx := context.Background()
_, err := ns.db.ExecContext(ctx, `
UPDATE notifications
SET read = TRUE
WHERE user_id = $1 AND read = FALSE
`, userID)
if err != nil {
return fmt.Errorf("failed to mark all notifications as read: %w", err)
}
return nil
}
// GetUnreadCount returns the count of unread notifications
func (ns *NotificationService) GetUnreadCount(userID uuid.UUID) (int, error) {
ctx := context.Background()
var count int
err := ns.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM notifications
WHERE user_id = $1 AND read = FALSE
`, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to get unread count: %w", err)
}
return count, nil
}
// DeleteNotification deletes a notification
func (ns *NotificationService) DeleteNotification(userID uuid.UUID, notificationID uuid.UUID) error {
ctx := context.Background()
_, err := ns.db.ExecContext(ctx, `
DELETE FROM notifications
WHERE id = $1 AND user_id = $2
`, notificationID, userID)
if err != nil {
return fmt.Errorf("failed to delete notification: %w", err)
}
return nil
}
// DeleteAllNotifications deletes all notifications for a user
func (ns *NotificationService) DeleteAllNotifications(userID uuid.UUID) error {
ctx := context.Background()
_, err := ns.db.ExecContext(ctx, `
DELETE FROM notifications
WHERE user_id = $1
`, userID)
if err != nil {
return fmt.Errorf("failed to delete all notifications: %w", err)
}
return nil
}
// NotificationPrefs represents notification preferences (N1.3, F553)
type NotificationPrefs struct {
PushFollow bool `json:"push_follow"`
PushLike bool `json:"push_like"`
PushComment bool `json:"push_comment"`
PushMessage bool `json:"push_message"`
PushMention bool `json:"push_mention"`
QuietHoursEnabled bool `json:"quiet_hours_enabled"`
QuietHoursStart string `json:"quiet_hours_start"` // "22:00"
QuietHoursEnd string `json:"quiet_hours_end"` // "08:00"
}
// GetPreferences returns notification preferences for a user
func (ns *NotificationService) GetPreferences(userID uuid.UUID) (*NotificationPrefs, error) {
ctx := context.Background()
prefs := &NotificationPrefs{PushFollow: true, PushLike: true, PushComment: true, PushMessage: true, PushMention: true}
var startNullable, endNullable sql.NullString
err := ns.db.QueryRowContext(ctx, `
SELECT push_follow, push_like, push_comment, push_message, push_mention,
COALESCE(quiet_hours_enabled, false),
quiet_hours_start::text, quiet_hours_end::text
FROM notification_preferences
WHERE user_id = $1
`, userID).Scan(&prefs.PushFollow, &prefs.PushLike, &prefs.PushComment, &prefs.PushMessage, &prefs.PushMention,
&prefs.QuietHoursEnabled, &startNullable, &endNullable)
if err == nil {
if startNullable.Valid {
prefs.QuietHoursStart = startNullable.String
}
if endNullable.Valid {
prefs.QuietHoursEnd = endNullable.String
}
}
if err == nil {
return prefs, nil
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to get preferences: %w", err)
}
return prefs, nil
}
// UpdatePreferences updates notification preferences (F553: quiet hours)
func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool,
quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string) error {
ctx := context.Background()
qhStart := nullIfEmpty(quietHoursStart)
qhEnd := nullIfEmpty(quietHoursEnd)
_, err := ns.db.ExecContext(ctx, `
INSERT INTO notification_preferences (user_id, push_follow, push_like, push_comment, push_message, push_mention,
quiet_hours_enabled, quiet_hours_start, quiet_hours_end, updated_at)
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), COALESCE($5, true), COALESCE($6, true),
COALESCE($7, false), $8::time, $9::time, NOW())
ON CONFLICT (user_id) DO UPDATE SET
push_follow = CASE WHEN $2 IS NOT NULL THEN $2 ELSE notification_preferences.push_follow END,
push_like = CASE WHEN $3 IS NOT NULL THEN $3 ELSE notification_preferences.push_like END,
push_comment = CASE WHEN $4 IS NOT NULL THEN $4 ELSE notification_preferences.push_comment END,
push_message = CASE WHEN $5 IS NOT NULL THEN $5 ELSE notification_preferences.push_message END,
push_mention = CASE WHEN $6 IS NOT NULL THEN $6 ELSE notification_preferences.push_mention END,
quiet_hours_enabled = CASE WHEN $7 IS NOT NULL THEN $7 ELSE notification_preferences.quiet_hours_enabled END,
quiet_hours_start = CASE WHEN $8::text IS NOT NULL AND $8::text != '' THEN $8::time ELSE notification_preferences.quiet_hours_start END,
quiet_hours_end = CASE WHEN $9::text IS NOT NULL AND $9::text != '' THEN $9::time ELSE notification_preferences.quiet_hours_end END,
updated_at = NOW()
`, userID, pushFollow, pushLike, pushComment, pushMessage, pushMention,
quietHoursEnabled, qhStart, qhEnd)
return err
}
func nullIfEmpty(s *string) interface{} {
if s == nil {
return nil
}
if *s == "" {
return nil
}
return *s
}