veza/veza-backend-api/internal/services/notification_service.go
senke 51af2d073f feat(notifications): N1.1-N1.3 Web Push subscription, send on events, preferences
- N1.1: POST /notifications/push/subscribe, PushService, migration 090
- N1.2: Send Web Push on follow/like/comment/message via CreateNotification
- N1.3: GET/PUT /notifications/preferences, migration 093
- Shared NotificationService with PushService for profile, track, comment handlers
- Fix MockSocialService GetGlobalFeed, GetTrendingHashtags for tests
2026-02-21 16:41:39 +01:00

268 lines
7.6 KiB
Go

package services
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
"veza-backend-api/internal/database"
"go.uber.org/zap"
)
// NotificationService handles notification operations
type NotificationService struct {
db *database.Database
logger *zap.Logger
pushService *PushService // optional, for N1.2 Web Push
}
// Notification represents a notification
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"`
}
// 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
}
// CreateNotification creates a new notification and optionally sends Web Push (N1.2)
func (ns *NotificationService) CreateNotification(userID uuid.UUID, notificationType, title, content, link string) error {
ctx := context.Background()
_, err := ns.db.ExecContext(ctx, `
INSERT INTO notifications (user_id, type, title, content, link)
VALUES ($1, $2, $3, $4, $5)
`, userID, notificationType, title, content, link)
if err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
// N1.2: Send Web Push if enabled and user has subscriptions
if ns.pushService != nil {
prefs, err := ns.GetPreferences(userID)
if err != nil {
ns.logger.Warn("failed to get push preferences", zap.Error(err))
return 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
}
// GetNotifications retrieves notifications for a user
func (ns *NotificationService) GetNotifications(userID uuid.UUID, unreadOnly bool) ([]Notification, error) {
ctx := context.Background()
query := `
SELECT id, user_id, type, title, content, link, read, created_at
FROM notifications
WHERE user_id = $1
`
args := []interface{}{userID}
if unreadOnly {
query += " AND read = FALSE"
}
query += " ORDER BY created_at DESC LIMIT 50"
rows, err := ns.db.QueryContext(ctx, query, args...)
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)
}
return notifications, 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)
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"`
}
// 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}
err := ns.db.QueryRowContext(ctx, `
SELECT push_follow, push_like, push_comment, push_message, push_mention
FROM notification_preferences
WHERE user_id = $1
`, userID).Scan(&prefs.PushFollow, &prefs.PushLike, &prefs.PushComment, &prefs.PushMessage, &prefs.PushMention)
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
func (ns *NotificationService) UpdatePreferences(userID uuid.UUID, pushFollow, pushLike, pushComment, pushMessage, pushMention *bool) error {
ctx := context.Background()
_, err := ns.db.ExecContext(ctx, `
INSERT INTO notification_preferences (user_id, push_follow, push_like, push_comment, push_message, push_mention, updated_at)
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), COALESCE($5, true), COALESCE($6, true), 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,
updated_at = NOW()
`, userID, pushFollow, pushLike, pushComment, pushMessage, pushMention)
return err
}