backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.
The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
491 lines
16 KiB
Go
491 lines
16 KiB
Go
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
|
|
}
|