sojorn/go-backend/internal/services/notification_service.go
Patrick Britton da5984d67c refactor: rename Go module from github.com/patbritton to gitlab.com/patrickbritton3
- Rename module path from github.com/patbritton/sojorn-backend to gitlab.com/patrickbritton3/sojorn/go-backend
- Updated 78 references across 41 files
- Matches new GitLab repository structure
2026-02-16 23:58:39 -06:00

544 lines
16 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"github.com/rs/zerolog/log"
)
type NotificationService struct {
notifRepo *repository.NotificationRepository
pushSvc *PushService
userRepo *repository.UserRepository
}
func NewNotificationService(notifRepo *repository.NotificationRepository, pushSvc *PushService, userRepo *repository.UserRepository) *NotificationService {
return &NotificationService{
notifRepo: notifRepo,
pushSvc: pushSvc,
userRepo: userRepo,
}
}
// ============================================================================
// High-Level Notification Methods (Called by Handlers)
// ============================================================================
// NotifyLike sends a notification when someone likes a post
func (s *NotificationService) NotifyLike(ctx context.Context, postAuthorID, actorID, postID string, postType string, emoji string) error {
if postAuthorID == actorID {
return nil // Don't notify self
}
if emoji == "" {
emoji = "❤️"
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(postAuthorID),
Type: models.NotificationTypeLike,
ActorID: uuid.MustParse(actorID),
PostID: uuidPtr(postID),
PostType: postType,
GroupKey: fmt.Sprintf("like:%s", postID), // Group likes on same post
Priority: models.PriorityNormal,
Metadata: map[string]interface{}{
"emoji": emoji,
},
})
}
// NotifyComment sends a notification when someone comments on a post
func (s *NotificationService) NotifyComment(ctx context.Context, postAuthorID, actorID, postID, commentID string, postType string) error {
if postAuthorID == actorID {
return nil
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(postAuthorID),
Type: models.NotificationTypeComment,
ActorID: uuid.MustParse(actorID),
PostID: uuidPtr(postID),
CommentID: uuidPtr(commentID),
PostType: postType,
GroupKey: fmt.Sprintf("comment:%s", postID),
Priority: models.PriorityNormal,
})
}
// NotifyReply sends a notification when someone replies to a comment
func (s *NotificationService) NotifyReply(ctx context.Context, commentAuthorID, actorID, postID, commentID string) error {
if commentAuthorID == actorID {
return nil
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(commentAuthorID),
Type: models.NotificationTypeReply,
ActorID: uuid.MustParse(actorID),
PostID: uuidPtr(postID),
CommentID: uuidPtr(commentID),
Priority: models.PriorityNormal,
})
}
// NotifyMention sends notifications to all mentioned users
func (s *NotificationService) NotifyMention(ctx context.Context, actorID, postID string, text string) error {
mentionedUserIDs, err := s.notifRepo.ExtractMentions(ctx, text)
if err != nil || len(mentionedUserIDs) == 0 {
return err
}
actorUUID := uuid.MustParse(actorID)
for _, userID := range mentionedUserIDs {
if userID == actorUUID {
continue // Don't notify self
}
err := s.sendNotification(ctx, models.PushNotificationRequest{
UserID: userID,
Type: models.NotificationTypeMention,
ActorID: actorUUID,
PostID: uuidPtr(postID),
Priority: models.PriorityHigh, // Mentions are high priority
})
if err != nil {
log.Warn().Err(err).Str("user_id", userID.String()).Msg("Failed to send mention notification")
}
}
return nil
}
// NotifyFollow sends a notification when someone follows a user
func (s *NotificationService) NotifyFollow(ctx context.Context, followedUserID, followerID string, isPending bool) error {
notifType := models.NotificationTypeFollow
if isPending {
notifType = models.NotificationTypeFollowRequest
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(followedUserID),
Type: notifType,
ActorID: uuid.MustParse(followerID),
Priority: models.PriorityNormal,
Metadata: map[string]interface{}{
"follower_id": followerID,
},
})
}
// NotifyFollowAccepted sends a notification when a follow request is accepted
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, followerID, acceptorID string) error {
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(followerID),
Type: models.NotificationTypeFollowAccept,
ActorID: uuid.MustParse(acceptorID),
Priority: models.PriorityNormal,
})
}
// NotifySave sends a notification when someone saves a post
func (s *NotificationService) NotifySave(ctx context.Context, postAuthorID, actorID, postID, postType string) error {
if postAuthorID == actorID {
return nil
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(postAuthorID),
Type: models.NotificationTypeSave,
ActorID: uuid.MustParse(actorID),
PostID: uuidPtr(postID),
PostType: postType,
GroupKey: fmt.Sprintf("save:%s", postID),
Priority: models.PriorityLow, // Saves are lower priority
})
}
// NotifyMessage sends a notification for new chat messages
func (s *NotificationService) NotifyMessage(ctx context.Context, receiverID, senderID, conversationID string) error {
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(receiverID),
Type: models.NotificationTypeMessage,
ActorID: uuid.MustParse(senderID),
Priority: models.PriorityHigh, // Messages are high priority
Metadata: map[string]interface{}{
"conversation_id": conversationID,
},
})
}
// NotifyBeaconVouch sends a notification when someone vouches for a beacon
func (s *NotificationService) NotifyBeaconVouch(ctx context.Context, beaconAuthorID, actorID, beaconID string) error {
if beaconAuthorID == actorID {
return nil
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(beaconAuthorID),
Type: models.NotificationTypeBeaconVouch,
ActorID: uuid.MustParse(actorID),
PostID: uuidPtr(beaconID),
PostType: "beacon",
GroupKey: fmt.Sprintf("beacon_vouch:%s", beaconID),
Priority: models.PriorityNormal,
})
}
// NotifyBeaconReport sends a notification when someone reports a beacon
func (s *NotificationService) NotifyBeaconReport(ctx context.Context, beaconAuthorID, actorID, beaconID string) error {
if beaconAuthorID == actorID {
return nil
}
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: uuid.MustParse(beaconAuthorID),
Type: models.NotificationTypeBeaconReport,
ActorID: uuid.MustParse(actorID),
PostID: uuidPtr(beaconID),
PostType: "beacon",
Priority: models.PriorityNormal,
})
}
// NotifyNSFWWarning sends a warning when a post is auto-labeled as NSFW
func (s *NotificationService) NotifyNSFWWarning(ctx context.Context, authorID string, postID string) error {
authorUUID := uuid.MustParse(authorID)
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: authorUUID,
Type: models.NotificationTypeNSFWWarning,
ActorID: authorUUID, // system-generated, actor is self
PostID: uuidPtr(postID),
PostType: "standard",
Priority: models.PriorityHigh,
})
}
// NotifyContentRemoved sends a notification when content is removed by AI moderation
func (s *NotificationService) NotifyContentRemoved(ctx context.Context, authorID string, postID string) error {
authorUUID := uuid.MustParse(authorID)
return s.sendNotification(ctx, models.PushNotificationRequest{
UserID: authorUUID,
Type: models.NotificationTypeContentRemoved,
ActorID: authorUUID, // system-generated
PostID: uuidPtr(postID),
PostType: "standard",
Priority: models.PriorityUrgent,
})
}
// ============================================================================
// Core Send Logic
// ============================================================================
func (s *NotificationService) sendNotification(ctx context.Context, req models.PushNotificationRequest) error {
// Check user preferences
shouldSend, err := s.notifRepo.ShouldSendPush(ctx, req.UserID.String(), req.Type)
if err != nil {
log.Warn().Err(err).Msg("Failed to check notification preferences")
}
// Get actor details
actor, err := s.userRepo.GetProfileByID(ctx, req.ActorID.String())
if err != nil {
log.Warn().Err(err).Msg("Failed to get actor profile for notification")
actor = &models.Profile{DisplayName: ptrString("Someone")}
}
if actor.DisplayName != nil {
req.ActorName = *actor.DisplayName
}
if actor.AvatarURL != nil {
req.ActorAvatar = *actor.AvatarURL
}
if actor.Handle != nil {
req.ActorHandle = *actor.Handle
}
// Create in-app notification record
notif := &models.Notification{
UserID: req.UserID,
Type: req.Type,
ActorID: req.ActorID,
PostID: req.PostID,
IsRead: false,
Priority: req.Priority,
Metadata: s.buildMetadata(req),
}
if req.CommentID != nil {
notif.CommentID = req.CommentID
}
if req.GroupKey != "" {
notif.GroupKey = &req.GroupKey
}
if err := s.notifRepo.CreateNotification(ctx, notif); err != nil {
log.Warn().Err(err).Msg("Failed to create in-app notification")
}
// Send push notification if enabled
if shouldSend && s.pushSvc != nil {
title, body, data := s.buildPushPayload(req)
// Get badge count for iOS/macOS
badge, _ := s.notifRepo.GetUnreadBadge(ctx, req.UserID.String())
err := s.pushSvc.SendPushWithBadge(ctx, req.UserID.String(), title, body, data, badge.TotalCount)
if err != nil {
log.Warn().Err(err).Str("user_id", req.UserID.String()).Msg("Failed to send push notification")
}
}
return nil
}
func (s *NotificationService) buildMetadata(req models.PushNotificationRequest) json.RawMessage {
data := map[string]interface{}{
"actor_name": req.ActorName,
"post_type": req.PostType,
}
if req.PostID != nil {
data["post_id"] = req.PostID.String()
}
if req.CommentID != nil {
data["comment_id"] = req.CommentID.String()
}
if req.PostPreview != "" {
data["post_preview"] = req.PostPreview
}
for k, v := range req.Metadata {
data[k] = v
}
bytes, _ := json.Marshal(data)
return bytes
}
func (s *NotificationService) buildPushPayload(req models.PushNotificationRequest) (title, body string, data map[string]string) {
actorName := req.ActorName
if actorName == "" {
actorName = "Someone"
}
data = map[string]string{
"type": req.Type,
}
if req.PostID != nil {
data["post_id"] = req.PostID.String()
}
if req.CommentID != nil {
data["comment_id"] = req.CommentID.String()
}
if req.ActorHandle != "" {
data["actor_handle"] = req.ActorHandle
}
if req.PostType != "" {
data["post_type"] = req.PostType
}
// Add target for navigation
target := s.getNavigationTarget(req.Type, req.PostType)
data["target"] = target
// Copy metadata
for k, v := range req.Metadata {
if str, ok := v.(string); ok {
data[k] = str
}
}
// Extract optional emoji
emoji := getString(req.Metadata, "emoji")
switch req.Type {
case models.NotificationTypeLike:
if emoji != "" {
title = fmt.Sprintf("%s %s", actorName, emoji)
body = fmt.Sprintf("%s %s your %s", actorName, emoji, s.formatPostType(req.PostType))
} else {
title = "New Like"
body = fmt.Sprintf("%s liked your %s", actorName, s.formatPostType(req.PostType))
}
case models.NotificationTypeComment:
title = fmt.Sprintf("%s 💬", actorName)
body = fmt.Sprintf("%s commented on your %s", actorName, s.formatPostType(req.PostType))
case models.NotificationTypeReply:
title = fmt.Sprintf("%s 💬", actorName)
body = fmt.Sprintf("%s replied to your comment", actorName)
case models.NotificationTypeMention:
title = "Mentioned"
body = fmt.Sprintf("%s mentioned you in a post", actorName)
case models.NotificationTypeFollow:
title = "New Follower"
body = fmt.Sprintf("%s started following you", actorName)
if req.ActorHandle != "" {
data["follower_id"] = req.ActorHandle
} else {
data["follower_id"] = req.ActorID.String()
}
case models.NotificationTypeFollowRequest:
title = "Follow Request"
body = fmt.Sprintf("%s wants to follow you", actorName)
if req.ActorHandle != "" {
data["follower_id"] = req.ActorHandle
} else {
data["follower_id"] = req.ActorID.String()
}
case models.NotificationTypeFollowAccept:
title = "Request Accepted"
body = fmt.Sprintf("%s accepted your follow request", actorName)
case models.NotificationTypeSave:
title = "Post Saved"
body = fmt.Sprintf("%s saved your %s", actorName, s.formatPostType(req.PostType))
case models.NotificationTypeMessage:
title = fmt.Sprintf("%s ✉️", actorName)
body = fmt.Sprintf("%s sent you a message", actorName)
case models.NotificationTypeBeaconVouch:
title = "Beacon Vouched"
body = fmt.Sprintf("%s vouched for your beacon", actorName)
data["beacon_id"] = req.PostID.String()
case models.NotificationTypeBeaconReport:
title = "Beacon Reported"
body = fmt.Sprintf("%s reported your beacon", actorName)
data["beacon_id"] = req.PostID.String()
case models.NotificationTypeQuipReaction:
if emoji != "" {
title = fmt.Sprintf("%s %s", actorName, emoji)
body = fmt.Sprintf("%s reacted %s to your quip", actorName, emoji)
} else {
title = "New Reaction"
body = fmt.Sprintf("%s reacted to your quip", actorName)
}
case models.NotificationTypeNSFWWarning:
title = "Content Labeled as Sensitive"
body = "Your post was automatically labeled as NSFW. Please label sensitive content when posting to avoid further action."
data["target"] = "main_feed"
case models.NotificationTypeContentRemoved:
title = "Content Removed"
body = "Your post was removed for violating community guidelines. You can appeal this decision in your profile settings."
data["target"] = "profile_settings"
default:
title = "Sojorn"
body = "You have a new notification"
}
return title, body, data
}
func (s *NotificationService) getNavigationTarget(notifType, postType string) string {
switch notifType {
case models.NotificationTypeMessage:
return "secure_chat"
case models.NotificationTypeFollow, models.NotificationTypeFollowRequest, models.NotificationTypeFollowAccept:
return "profile"
case models.NotificationTypeBeaconVouch, models.NotificationTypeBeaconReport:
return "beacon_map"
case models.NotificationTypeQuipReaction:
return "quip_feed"
default:
switch postType {
case "beacon":
return "beacon_map"
case "quip":
return "quip_feed"
default:
return "main_feed"
}
}
}
func (s *NotificationService) formatPostType(postType string) string {
switch postType {
case "beacon":
return "beacon"
case "quip":
return "quip"
default:
return "post"
}
}
// ============================================================================
// Legacy Compatibility Method
// ============================================================================
// CreateNotification is the legacy method for backwards compatibility
func (s *NotificationService) CreateNotification(ctx context.Context, userID, actorID, notificationType string, postID *string, commentID *string, metadata map[string]interface{}) error {
actorName := getString(metadata, "actor_name")
postType := getString(metadata, "post_type")
req := models.PushNotificationRequest{
UserID: uuid.MustParse(userID),
Type: notificationType,
ActorID: uuid.MustParse(actorID),
PostType: postType,
Priority: models.PriorityNormal,
Metadata: metadata,
}
if postID != nil {
req.PostID = uuidPtr(*postID)
}
if commentID != nil {
req.CommentID = uuidPtr(*commentID)
}
if actorName != "" {
req.ActorName = actorName
}
return s.sendNotification(ctx, req)
}
// ============================================================================
// Helpers
// ============================================================================
func uuidPtr(s string) *uuid.UUID {
if s == "" {
return nil
}
u, err := uuid.Parse(s)
if err != nil {
return nil
}
return &u
}
func ptrString(s string) *string {
return &s
}
// Helper functions
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok {
return str
}
if sPtr, ok := val.(*string); ok && sPtr != nil {
return *sPtr
}
}
return ""
}