sojorn/go-backend/internal/repository/notification_repository.go
2026-02-15 00:33:24 -06:00

597 lines
19 KiB
Go

package repository
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/models"
"github.com/rs/zerolog/log"
)
type NotificationRepository struct {
pool *pgxpool.Pool
}
func NewNotificationRepository(pool *pgxpool.Pool) *NotificationRepository {
return &NotificationRepository{pool: pool}
}
// ============================================================================
// FCM Token Management
// ============================================================================
func (r *NotificationRepository) UpsertFCMToken(ctx context.Context, token *models.UserFCMToken) error {
query := `
INSERT INTO public.user_fcm_tokens (user_id, token, device_type, created_at, last_updated)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, token)
DO UPDATE SET
device_type = EXCLUDED.device_type,
last_updated = EXCLUDED.last_updated
`
_, err := r.pool.Exec(ctx, query,
token.UserID,
token.FCMToken,
token.Platform,
time.Now(),
time.Now(),
)
return err
}
func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID string) ([]string, error) {
query := `
SELECT token
FROM public.user_fcm_tokens
WHERE user_id = $1::uuid
`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var tokens []string
for rows.Next() {
var token string
if err := rows.Scan(&token); err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, nil
}
func (r *NotificationRepository) DeleteFCMToken(ctx context.Context, userID string, token string) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM public.user_fcm_tokens
WHERE user_id = $1::uuid AND token = $2
`, userID, token)
return err
}
func (r *NotificationRepository) DeleteAllFCMTokensForUser(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid
`, userID)
return err
}
// ============================================================================
// Notification CRUD
// ============================================================================
func (r *NotificationRepository) CreateNotification(ctx context.Context, notif *models.Notification) error {
query := `
INSERT INTO public.notifications (user_id, type, actor_id, post_id, comment_id, is_read, metadata, group_key, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at
`
priority := notif.Priority
if priority == "" {
priority = models.PriorityNormal
}
err := r.pool.QueryRow(ctx, query,
notif.UserID, notif.Type, notif.ActorID, notif.PostID, notif.CommentID, notif.IsRead, notif.Metadata, notif.GroupKey, priority,
).Scan(&notif.ID, &notif.CreatedAt)
return err
}
func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int, includeArchived bool) ([]models.Notification, error) {
whereClause := "WHERE n.user_id = $1::uuid AND n.archived_at IS NULL AND n.type != 'message'"
if includeArchived {
whereClause = "WHERE n.user_id = $1::uuid AND n.archived_at IS NOT NULL AND n.type != 'message'"
}
query := `
SELECT
n.id, n.user_id, n.type, n.actor_id, n.post_id, n.comment_id, n.is_read, n.created_at, n.archived_at, n.metadata,
COALESCE(n.group_key, '') as group_key,
COALESCE(n.priority, 'normal') as priority,
pr.handle, pr.display_name, COALESCE(pr.avatar_url, ''),
po.image_url,
LEFT(po.body, 100) as post_body
FROM public.notifications n
JOIN public.profiles pr ON n.actor_id = pr.id
LEFT JOIN public.posts po ON n.post_id = po.id
` + whereClause + `
ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
notifications := []models.Notification{}
for rows.Next() {
var n models.Notification
var groupKey string
var postImageURL *string
var postBody *string
err := rows.Scan(
&n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.ArchivedAt, &n.Metadata,
&groupKey, &n.Priority,
&n.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL,
&postImageURL, &postBody,
)
if err != nil {
return nil, err
}
if groupKey != "" {
n.GroupKey = &groupKey
}
n.PostImageURL = postImageURL
n.PostBody = postBody
notifications = append(notifications, n)
}
return notifications, nil
}
// GetGroupedNotifications returns notifications with grouping (e.g., "5 people liked your post")
func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, userID string, limit, offset int, includeArchived bool) ([]models.Notification, error) {
whereClause := "WHERE n.user_id = $1::uuid AND n.archived_at IS NULL AND n.type != 'message'"
if includeArchived {
whereClause = "WHERE n.user_id = $1::uuid AND n.archived_at IS NOT NULL AND n.type != 'message'"
}
query := `
WITH ranked AS (
SELECT
n.*,
pr.handle as actor_handle,
pr.display_name as actor_display_name,
COALESCE(pr.avatar_url, '') as actor_avatar_url,
po.image_url as post_image_url,
LEFT(po.body, 100) as post_body,
COUNT(*) OVER (PARTITION BY n.group_key) as group_count,
ROW_NUMBER() OVER (PARTITION BY n.group_key ORDER BY n.created_at DESC) as rn
FROM public.notifications n
JOIN public.profiles pr ON n.actor_id = pr.id
LEFT JOIN public.posts po ON n.post_id = po.id
` + whereClause + `
)
SELECT
id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, archived_at, metadata,
COALESCE(group_key, '') as group_key,
COALESCE(priority, 'normal') as priority,
actor_handle, actor_display_name, actor_avatar_url,
post_image_url, post_body,
group_count
FROM ranked
WHERE rn = 1 OR group_key IS NULL
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
notifications := []models.Notification{}
for rows.Next() {
var n models.Notification
var groupKey string
err := rows.Scan(
&n.ID, &n.UserID, &n.Type, &n.ActorID, &n.PostID, &n.CommentID, &n.IsRead, &n.CreatedAt, &n.ArchivedAt, &n.Metadata,
&groupKey, &n.Priority,
&n.ActorHandle, &n.ActorDisplayName, &n.ActorAvatarURL,
&n.PostImageURL, &n.PostBody,
&n.GroupCount,
)
if err != nil {
return nil, err
}
if groupKey != "" {
n.GroupKey = &groupKey
}
notifications = append(notifications, n)
}
return notifications, nil
}
func (r *NotificationRepository) MarkAsRead(ctx context.Context, notificationID, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.notifications SET is_read = TRUE
WHERE id = $1::uuid AND user_id = $2::uuid
`, notificationID, userID)
return err
}
func (r *NotificationRepository) MarkAllAsRead(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.notifications SET is_read = TRUE
WHERE user_id = $1::uuid AND is_read = FALSE
`, userID)
return err
}
func (r *NotificationRepository) DeleteNotification(ctx context.Context, notificationID, userID string) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM public.notifications
WHERE id = $1::uuid AND user_id = $2::uuid
`, notificationID, userID)
return err
}
func (r *NotificationRepository) MarkNotificationsAsRead(ctx context.Context, ids []string, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.notifications SET is_read = TRUE
WHERE id = ANY($1::uuid[]) AND user_id = $2::uuid
`, ids, userID)
return err
}
func (r *NotificationRepository) ArchiveNotifications(ctx context.Context, ids []string, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.notifications SET archived_at = NOW(), is_read = TRUE
WHERE id = ANY($1::uuid[]) AND user_id = $2::uuid
`, ids, userID)
if err != nil {
return err
}
// Recalculate badge count excluding archived and message types
_, err = r.pool.Exec(ctx, `
UPDATE profiles SET unread_notification_count = (
SELECT COUNT(*) FROM notifications WHERE user_id = $1::uuid AND is_read = FALSE AND archived_at IS NULL AND type != 'message'
) WHERE id = $1::uuid
`, userID)
return err
}
func (r *NotificationRepository) ArchiveAllNotifications(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE public.notifications SET archived_at = NOW(), is_read = TRUE
WHERE user_id = $1::uuid AND archived_at IS NULL
`, userID)
if err != nil {
return err
}
// Reset badge count — all notifications are now archived and read
_, err = r.pool.Exec(ctx, `
UPDATE profiles SET unread_notification_count = 0 WHERE id = $1::uuid
`, userID)
return err
}
func (r *NotificationRepository) GetUnreadCount(ctx context.Context, userID string) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM public.notifications
WHERE user_id = $1::uuid AND is_read = FALSE AND archived_at IS NULL AND type != 'message'
`, userID).Scan(&count)
return count, err
}
func (r *NotificationRepository) GetUnreadBadge(ctx context.Context, userID string) (*models.UnreadBadge, error) {
badge := &models.UnreadBadge{}
// Get notification count (live query, excludes archived and chat messages)
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM public.notifications
WHERE user_id = $1::uuid AND is_read = FALSE AND archived_at IS NULL AND type != 'message'
`, userID).Scan(&badge.NotificationCount)
if err != nil {
return nil, err
}
// Get unread message count
err = r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM encrypted_messages
WHERE receiver_id = $1::uuid AND is_read = FALSE
`, userID).Scan(&badge.MessageCount)
if err != nil {
// Table might not exist or column missing, ignore
badge.MessageCount = 0
}
badge.TotalCount = badge.NotificationCount + badge.MessageCount
return badge, nil
}
// ============================================================================
// Notification Preferences
// ============================================================================
func (r *NotificationRepository) GetNotificationPreferences(ctx context.Context, userID string) (*models.NotificationPreferences, error) {
prefs := &models.NotificationPreferences{}
err := r.pool.QueryRow(ctx, `
SELECT
user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions,
push_follows, push_follow_requests, push_messages, push_saves, push_beacons,
email_enabled, email_digest_frequency, quiet_hours_enabled,
quiet_hours_start::text, quiet_hours_end::text, show_badge_count,
created_at, updated_at
FROM notification_preferences
WHERE user_id = $1::uuid
`, userID).Scan(
&prefs.UserID, &prefs.PushEnabled, &prefs.PushLikes, &prefs.PushComments, &prefs.PushReplies, &prefs.PushMentions,
&prefs.PushFollows, &prefs.PushFollowRequests, &prefs.PushMessages, &prefs.PushSaves, &prefs.PushBeacons,
&prefs.EmailEnabled, &prefs.EmailDigestFrequency, &prefs.QuietHoursEnabled,
&prefs.QuietHoursStart, &prefs.QuietHoursEnd, &prefs.ShowBadgeCount,
&prefs.CreatedAt, &prefs.UpdatedAt,
)
if err != nil {
// Return defaults if not found
return r.createDefaultPreferences(ctx, userID)
}
return prefs, nil
}
func (r *NotificationRepository) createDefaultPreferences(ctx context.Context, userID string) (*models.NotificationPreferences, error) {
userUUID, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
prefs := &models.NotificationPreferences{
UserID: userUUID,
PushEnabled: true,
PushLikes: true,
PushComments: true,
PushReplies: true,
PushMentions: true,
PushFollows: true,
PushFollowRequests: true,
PushMessages: true,
PushSaves: true,
PushBeacons: true,
EmailEnabled: false,
EmailDigestFrequency: "never",
QuietHoursEnabled: false,
ShowBadgeCount: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = r.pool.Exec(ctx, `
INSERT INTO notification_preferences (user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions,
push_follows, push_follow_requests, push_messages, push_saves, push_beacons,
email_enabled, email_digest_frequency, quiet_hours_enabled, show_badge_count)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (user_id) DO NOTHING
`,
prefs.UserID, prefs.PushEnabled, prefs.PushLikes, prefs.PushComments, prefs.PushReplies, prefs.PushMentions,
prefs.PushFollows, prefs.PushFollowRequests, prefs.PushMessages, prefs.PushSaves, prefs.PushBeacons,
prefs.EmailEnabled, prefs.EmailDigestFrequency, prefs.QuietHoursEnabled, prefs.ShowBadgeCount,
)
return prefs, err
}
func (r *NotificationRepository) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO notification_preferences (
user_id, push_enabled, push_likes, push_comments, push_replies, push_mentions,
push_follows, push_follow_requests, push_messages, push_saves, push_beacons,
email_enabled, email_digest_frequency, quiet_hours_enabled, quiet_hours_start, quiet_hours_end,
show_badge_count, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::time, $16::time, $17, NOW())
ON CONFLICT (user_id) DO UPDATE SET
push_enabled = EXCLUDED.push_enabled,
push_likes = EXCLUDED.push_likes,
push_comments = EXCLUDED.push_comments,
push_replies = EXCLUDED.push_replies,
push_mentions = EXCLUDED.push_mentions,
push_follows = EXCLUDED.push_follows,
push_follow_requests = EXCLUDED.push_follow_requests,
push_messages = EXCLUDED.push_messages,
push_saves = EXCLUDED.push_saves,
push_beacons = EXCLUDED.push_beacons,
email_enabled = EXCLUDED.email_enabled,
email_digest_frequency = EXCLUDED.email_digest_frequency,
quiet_hours_enabled = EXCLUDED.quiet_hours_enabled,
quiet_hours_start = EXCLUDED.quiet_hours_start,
quiet_hours_end = EXCLUDED.quiet_hours_end,
show_badge_count = EXCLUDED.show_badge_count,
updated_at = NOW()
`,
prefs.UserID, prefs.PushEnabled, prefs.PushLikes, prefs.PushComments, prefs.PushReplies, prefs.PushMentions,
prefs.PushFollows, prefs.PushFollowRequests, prefs.PushMessages, prefs.PushSaves, prefs.PushBeacons,
prefs.EmailEnabled, prefs.EmailDigestFrequency, prefs.QuietHoursEnabled, prefs.QuietHoursStart, prefs.QuietHoursEnd,
prefs.ShowBadgeCount,
)
return err
}
// ShouldSendPush checks user preferences and quiet hours to determine if push should be sent
func (r *NotificationRepository) ShouldSendPush(ctx context.Context, userID, notificationType string) (bool, error) {
prefs, err := r.GetNotificationPreferences(ctx, userID)
if err != nil {
log.Warn().Err(err).Str("user_id", userID).Msg("Failed to get notification preferences, defaulting to send")
return true, nil
}
if !prefs.PushEnabled {
return false, nil
}
// Check quiet hours
if prefs.QuietHoursEnabled && prefs.QuietHoursStart != nil && prefs.QuietHoursEnd != nil {
if r.isInQuietHours(*prefs.QuietHoursStart, *prefs.QuietHoursEnd) {
return false, nil
}
}
// Check specific notification type
switch notificationType {
case models.NotificationTypeLike:
return prefs.PushLikes, nil
case models.NotificationTypeComment:
return prefs.PushComments, nil
case models.NotificationTypeReply:
return prefs.PushReplies, nil
case models.NotificationTypeMention:
return prefs.PushMentions, nil
case models.NotificationTypeFollow:
return prefs.PushFollows, nil
case models.NotificationTypeFollowRequest:
return prefs.PushFollowRequests, nil
case models.NotificationTypeMessage:
return prefs.PushMessages, nil
case models.NotificationTypeSave:
return prefs.PushSaves, nil
case models.NotificationTypeBeaconVouch, models.NotificationTypeBeaconReport:
return prefs.PushBeacons, nil
default:
return true, nil
}
}
func (r *NotificationRepository) isInQuietHours(start, end string) bool {
now := time.Now().UTC()
currentTime := now.Format("15:04:05")
// Simple string comparison for time ranges
// Handle cases where quiet hours span midnight
if start > end {
// Spans midnight: 22:00 -> 08:00
return currentTime >= start || currentTime <= end
}
// Same day: 23:00 -> 23:59
return currentTime >= start && currentTime <= end
}
// ============================================================================
// Mention Extraction
// ============================================================================
// ExtractMentions finds @username patterns in text and returns user IDs
func (r *NotificationRepository) ExtractMentions(ctx context.Context, text string) ([]uuid.UUID, error) {
// Extract @mentions using regex
mentions := extractMentionHandles(text)
if len(mentions) == 0 {
return nil, nil
}
// Look up user IDs for handles
query := `SELECT id FROM profiles WHERE handle = ANY($1)`
rows, err := r.pool.Query(ctx, query, mentions)
if err != nil {
return nil, err
}
defer rows.Close()
var userIDs []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
continue
}
userIDs = append(userIDs, id)
}
return userIDs, nil
}
func extractMentionHandles(text string) []string {
var mentions []string
inMention := false
current := ""
for i, r := range text {
if r == '@' {
inMention = true
current = ""
continue
}
if inMention {
// Valid handle characters: alphanumeric and underscore
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
current += string(r)
} else {
if len(current) > 0 {
mentions = append(mentions, current)
}
inMention = false
current = ""
}
}
// Check end of string
if i == len(text)-1 && inMention && len(current) > 0 {
mentions = append(mentions, current)
}
}
return mentions
}
// ============================================================================
// Notification Cleanup
// ============================================================================
// DeleteOldNotifications removes notifications older than the specified days
func (r *NotificationRepository) DeleteOldNotifications(ctx context.Context, daysOld int) (int64, error) {
result, err := r.pool.Exec(ctx, `
DELETE FROM notifications
WHERE created_at < NOW() - INTERVAL '1 day' * $1
`, daysOld)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
// ArchiveOldNotifications marks old notifications as archived instead of deleting
func (r *NotificationRepository) ArchiveOldNotifications(ctx context.Context, daysOld int) (int64, error) {
result, err := r.pool.Exec(ctx, `
UPDATE notifications
SET archived_at = NOW()
WHERE created_at < NOW() - INTERVAL '1 day' * $1 AND archived_at IS NULL
`, daysOld)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
// ============================================================================
// Helper for building metadata JSON
// ============================================================================
func BuildNotificationMetadata(data map[string]interface{}) json.RawMessage {
if data == nil {
return json.RawMessage("{}")
}
bytes, err := json.Marshal(data)
if err != nil {
return json.RawMessage("{}")
}
return bytes
}