package services import ( "context" "database/sql" "encoding/json" "fmt" "regexp" "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) } var gk interface{} = groupKey if groupKey == "" { gk = nil } _, 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, $6, $7, $8::jsonb) `, userID, notificationType, title, content, link, gk, 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, COALESCE(group_key, ''), COALESCE(actor_count, 1), COALESCE(CAST(metadata AS TEXT), '') 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( ¬ification.ID, ¬ification.UserID, ¬ification.Type, ¬ification.Title, ¬ification.Content, ¬ification.Link, ¬ification.Read, ¬ification.CreatedAt, ¬ification.GroupKey, ¬ification.ActorCount, ¬ification.Metadata, ); 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, F552) 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" WeeklyDigestEnabled bool `json:"weekly_digest_enabled"` } // GetPreferences returns notification preferences for a user func (ns *NotificationService) GetPreferences(userID uuid.UUID) (*NotificationPrefs, error) { ctx := context.Background() // v0.10.5: default push_message and push_follow only; others off (anti-FOMO) prefs := &NotificationPrefs{PushFollow: true, PushMessage: true, PushLike: false, PushComment: false, PushMention: false} 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, COALESCE(weekly_digest_enabled, false) FROM notification_preferences WHERE user_id = $1 `, userID).Scan(&prefs.PushFollow, &prefs.PushLike, &prefs.PushComment, &prefs.PushMessage, &prefs.PushMention, &prefs.QuietHoursEnabled, &startNullable, &endNullable, &prefs.WeeklyDigestEnabled) 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, F552: digest) func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool, quietHoursEnabled *bool, quietHoursStart, quietHoursEnd *string, weeklyDigestEnabled *bool) error { ctx := context.Background() qhStart := nullIfEmpty(quietHoursStart) qhEnd := nullIfEmpty(quietHoursEnd) // v0.10.5: defaults — push_follow, push_message true; push_like, push_comment, push_mention false _, 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, weekly_digest_enabled, updated_at) VALUES ($1, COALESCE($2, true), COALESCE($3, false), COALESCE($4, false), COALESCE($5, true), COALESCE($6, false), COALESCE($7, false), $8::time, $9::time, COALESCE($10, false), 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, weekly_digest_enabled = CASE WHEN $10 IS NOT NULL THEN $10 ELSE notification_preferences.weekly_digest_enabled END, updated_at = NOW() `, userID, pushFollow, pushLike, pushComment, pushMessage, pushMention, quietHoursEnabled, qhStart, qhEnd, weeklyDigestEnabled) return err } func nullIfEmpty(s *string) interface{} { if s == nil { return nil } if *s == "" { return nil } return *s }