From 72ae6447580f31be61645a8b149b0709a0b3ccf3 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Wed, 4 Feb 2026 10:51:01 -0600 Subject: [PATCH] feat: notification system refinements and api route fixes --- go-backend/cmd/api/main.go | 12 +- go-backend/cmd/search_migration/main.go | 54 +++++++++ go-backend/directus-docker-compose.yml | 2 +- go-backend/internal/config/config.go | 8 +- .../20260126000008_fix_media_urls.up.sql | 10 +- .../20260204000001_search_optimization.up.sql | 13 +++ go-backend/internal/handlers/auth_handler.go | 10 +- .../internal/handlers/category_handler.go | 2 +- go-backend/internal/handlers/chat_handler.go | 40 +++---- .../internal/handlers/discover_handler.go | 2 +- .../internal/handlers/notification_handler.go | 69 ++++++++++++ go-backend/internal/handlers/post_handler.go | 11 +- .../internal/handlers/search_handler.go | 4 +- go-backend/internal/handlers/user_handler.go | 103 ++++++------------ .../repository/notification_repository.go | 28 ++++- .../internal/repository/post_repository.go | 19 ++-- .../internal/repository/user_repository.go | 30 +++-- go-backend/internal/services/email_service.go | 8 +- go-backend/internal/services/push_service.go | 6 +- go-backend/nginx_sojorn.conf | 10 +- 20 files changed, 285 insertions(+), 156 deletions(-) create mode 100644 go-backend/cmd/search_migration/main.go create mode 100644 go-backend/internal/database/migrations/20260204000001_search_optimization.up.sql diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index a3c63ed..cfc2523 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -117,9 +117,9 @@ func main() { hub := realtime.NewHub() wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret) - userHandler := handlers.NewUserHandler(userRepo, postRepo, pushService, assetService) + userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService) postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService) - chatHandler := handlers.NewChatHandler(chatRepo, pushService, hub) + chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub) authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService) categoryHandler := handlers.NewCategoryHandler(categoryRepo) keyHandler := handlers.NewKeyHandler(userRepo) @@ -186,6 +186,7 @@ func main() { authorized.GET("/profile", userHandler.GetProfile) authorized.PATCH("/profile", userHandler.UpdateProfile) authorized.POST("/complete-onboarding", authHandler.CompleteOnboarding) + authorized.GET("/profile/trust-state", userHandler.GetTrustState) settings := authorized.Group("/settings") { @@ -202,6 +203,7 @@ func main() { users.POST("/:id/accept", userHandler.AcceptFollowRequest) users.DELETE("/:id/reject", userHandler.RejectFollowRequest) users.GET("/requests", userHandler.GetPendingFollowRequests) + users.POST("/requests", userHandler.GetPendingFollowRequests) users.GET("/:id/posts", postHandler.GetProfilePosts) users.GET("/:id/saved", userHandler.GetSavedPosts) users.GET("/me/liked", userHandler.GetLikedPosts) @@ -210,8 +212,7 @@ func main() { users.GET("/blocked", userHandler.GetBlockedUsers) users.POST("/report", userHandler.ReportUser) users.POST("/block_by_handle", userHandler.BlockUserByHandle) - users.DELETE("/notifications/device", userHandler.RemoveFCMToken) - users.POST("/notifications/device", userHandler.RegisterFCMToken) + } authorized.POST("/posts", postHandler.CreatePost) @@ -296,7 +297,10 @@ func main() { authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount) authorized.GET("/notifications/badge", notificationHandler.GetBadgeCount) authorized.PUT("/notifications/:id/read", notificationHandler.MarkAsRead) + authorized.POST("/notifications/read", notificationHandler.BulkMarkAsRead) authorized.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead) + authorized.POST("/notifications/archive", notificationHandler.Archive) + authorized.POST("/notifications/archive-all", notificationHandler.ArchiveAll) authorized.DELETE("/notifications/:id", notificationHandler.DeleteNotification) authorized.GET("/notifications/preferences", notificationHandler.GetNotificationPreferences) authorized.PUT("/notifications/preferences", notificationHandler.UpdateNotificationPreferences) diff --git a/go-backend/cmd/search_migration/main.go b/go-backend/cmd/search_migration/main.go new file mode 100644 index 0000000..0ff67bd --- /dev/null +++ b/go-backend/cmd/search_migration/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/patbritton/sojorn-backend/internal/config" + "github.com/patbritton/sojorn-backend/internal/database" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + cfg := config.LoadConfig() + if cfg.DatabaseURL == "" { + log.Fatal().Msg("DATABASE_URL is not set") + } + + log.Info().Msg("Connecting to database...") + pool, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatal().Err(err).Msg("Failed to connect") + } + defer pool.Close() + + // SQL to enable pg_trgm and create GIN indices + sql := ` + -- Enable pg_trgm extension + CREATE EXTENSION IF NOT EXISTS pg_trgm; + + -- Create GIN indices for profiles + CREATE INDEX IF NOT EXISTS idx_profiles_handle_trgm ON profiles USING gin (handle gin_trgm_ops); + CREATE INDEX IF NOT EXISTS idx_profiles_display_name_trgm ON profiles USING gin (display_name gin_trgm_ops); + + -- Create GIN index for post body + CREATE INDEX IF NOT EXISTS idx_posts_body_trgm ON posts USING gin (body gin_trgm_ops); + + -- Create GIN index for post tags + CREATE INDEX IF NOT EXISTS idx_posts_tags_gin ON posts USING gin (tags); + ` + + log.Info().Msg("Applying search optimization indexes...") + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + _, err = pool.Exec(ctx, sql) + if err != nil { + log.Fatal().Err(err).Msg("Failed to apply migration") + } + + log.Info().Msg("Successfully applied search optimization!") +} diff --git a/go-backend/directus-docker-compose.yml b/go-backend/directus-docker-compose.yml index edb3e60..331d7d8 100644 --- a/go-backend/directus-docker-compose.yml +++ b/go-backend/directus-docker-compose.yml @@ -23,4 +23,4 @@ services: ADMIN_EMAIL: "admin@sojorn.com" ADMIN_PASSWORD: "Password123!" - PUBLIC_URL: "https://gosojorn.com/cms" + PUBLIC_URL: "https://sojorn.net/cms" diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index 5374291..d6ff331 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -59,18 +59,18 @@ func LoadConfig() *Config { SMTPPort: getEnvInt("SMTP_PORT", 587), SMTPUser: getEnv("SMTP_USER", ""), SMTPPass: getEnv("SMTP_PASS", ""), - SMTPFrom: getEnv("SMTP_FROM", "no-reply@gosojorn.com"), + SMTPFrom: getEnv("SMTP_FROM", "no-reply@sojorn.net"), SenderAPIToken: getEnv("SENDER_API_TOKEN", ""), SendPulseID: getEnv("SENDPULSE_ID", ""), SendPulseSecret: getEnv("SENDPULSE_SECRET", ""), R2SigningSecret: getEnv("R2_SIGNING_SECRET", ""), // Default to the public CDN domain to avoid mixed-content/http defaults. - R2PublicBaseURL: getEnv("R2_PUBLIC_BASE_URL", "https://img.gosojorn.com"), + R2PublicBaseURL: getEnv("R2_PUBLIC_BASE_URL", "https://img.sojorn.net"), FirebaseCredentialsFile: getEnv("FIREBASE_CREDENTIALS_FILE", "firebase-service-account.json"), R2AccountID: getEnv("R2_ACCOUNT_ID", ""), R2APIToken: getEnv("R2_API_TOKEN", ""), - R2ImgDomain: getEnv("R2_IMG_DOMAIN", "img.gosojorn.com"), - R2VidDomain: getEnv("R2_VID_DOMAIN", "quips.gosojorn.com"), + R2ImgDomain: getEnv("R2_IMG_DOMAIN", "img.sojorn.net"), + R2VidDomain: getEnv("R2_VID_DOMAIN", "quips.sojorn.net"), R2Endpoint: getEnv("R2_ENDPOINT", ""), R2AccessKey: getEnv("R2_ACCESS_KEY", ""), R2SecretKey: getEnv("R2_SECRET_KEY", ""), diff --git a/go-backend/internal/database/migrations/20260126000008_fix_media_urls.up.sql b/go-backend/internal/database/migrations/20260126000008_fix_media_urls.up.sql index d94f99f..4543898 100644 --- a/go-backend/internal/database/migrations/20260126000008_fix_media_urls.up.sql +++ b/go-backend/internal/database/migrations/20260126000008_fix_media_urls.up.sql @@ -1,21 +1,21 @@ -- Fix R2 URLs in profiles UPDATE profiles -SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') +SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g') WHERE avatar_url LIKE '%r2.cloudflarestorage.com%'; UPDATE profiles -SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') +SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g') WHERE cover_url LIKE '%r2.cloudflarestorage.com%'; -- Fix R2 URLs in posts UPDATE posts -SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') +SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g') WHERE image_url LIKE '%r2.cloudflarestorage.com%'; UPDATE posts -SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://quips.gosojorn.com/', 'g') +SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://quips.sojorn.net/', 'g') WHERE video_url LIKE '%r2.cloudflarestorage.com%'; UPDATE posts -SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') +SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g') WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com%'; diff --git a/go-backend/internal/database/migrations/20260204000001_search_optimization.up.sql b/go-backend/internal/database/migrations/20260204000001_search_optimization.up.sql new file mode 100644 index 0000000..dfee53e --- /dev/null +++ b/go-backend/internal/database/migrations/20260204000001_search_optimization.up.sql @@ -0,0 +1,13 @@ +-- Enable pg_trgm extension +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Create GIN indices for profiles +CREATE INDEX IF NOT EXISTS idx_profiles_handle_trgm ON profiles USING gin (handle gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_profiles_display_name_trgm ON profiles USING gin (display_name gin_trgm_ops); + +-- Create GIN index for post body +CREATE INDEX IF NOT EXISTS idx_posts_body_trgm ON posts USING gin (body gin_trgm_ops); + +-- Create GIN index for post tags +-- Assuming tags is a text[] column +CREATE INDEX IF NOT EXISTS idx_posts_tags_gin ON posts USING gin (tags); diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index 5c150fc..3e7abe6 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -210,7 +210,7 @@ func (h *AuthHandler) CompleteOnboarding(c *gin.Context) { func (h *AuthHandler) VerifyEmail(c *gin.Context) { rawToken := c.Query("token") if rawToken == "" { - c.Redirect(http.StatusFound, "https://gosojorn.com/verify-error?reason=invalid_token") + c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token") return } @@ -219,19 +219,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString) if err != nil { - c.Redirect(http.StatusFound, "https://gosojorn.com/verify-error?reason=invalid_token") + c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token") return } if time.Now().After(expiresAt) { h.repo.DeleteVerificationToken(c.Request.Context(), hashString) - c.Redirect(http.StatusFound, "https://gosojorn.com/verify-error?reason=expired") + c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=expired") return } // Activate user if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil { - c.Redirect(http.StatusFound, "https://gosojorn.com/verify-error?reason=server_error") + c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=server_error") return } @@ -251,7 +251,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { // Cleanup _ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString) - c.Redirect(http.StatusFound, "https://gosojorn.com/verified") + c.Redirect(http.StatusFound, "https://sojorn.net/verified") } func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) { diff --git a/go-backend/internal/handlers/category_handler.go b/go-backend/internal/handlers/category_handler.go index 0745604..1e9b9df 100644 --- a/go-backend/internal/handlers/category_handler.go +++ b/go-backend/internal/handlers/category_handler.go @@ -31,7 +31,7 @@ type CategorySettingRequest struct { } type SetCategorySettingsRequest struct { - Settings []CategorySettingRequest `json:"settings" binding:"required"` + Settings []CategorySettingRequest `json:"settings"` } func (h *CategoryHandler) SetUserCategorySettings(c *gin.Context) { diff --git a/go-backend/internal/handlers/chat_handler.go b/go-backend/internal/handlers/chat_handler.go index 062630d..92305c2 100644 --- a/go-backend/internal/handlers/chat_handler.go +++ b/go-backend/internal/handlers/chat_handler.go @@ -8,7 +8,6 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/patbritton/sojorn-backend/internal/models" "github.com/patbritton/sojorn-backend/internal/realtime" "github.com/patbritton/sojorn-backend/internal/repository" "github.com/patbritton/sojorn-backend/internal/services" @@ -16,16 +15,16 @@ import ( ) type ChatHandler struct { - chatRepo *repository.ChatRepository - pushService *services.PushService - hub *realtime.Hub + chatRepo *repository.ChatRepository + notificationService *services.NotificationService + hub *realtime.Hub } -func NewChatHandler(chatRepo *repository.ChatRepository, pushService *services.PushService, hub *realtime.Hub) *ChatHandler { +func NewChatHandler(chatRepo *repository.ChatRepository, notificationService *services.NotificationService, hub *realtime.Hub) *ChatHandler { return &ChatHandler{ - chatRepo: chatRepo, - pushService: pushService, - hub: hub, + chatRepo: chatRepo, + notificationService: notificationService, + hub: hub, } } @@ -143,25 +142,12 @@ func (h *ChatHandler) SendMessage(c *gin.Context) { // 1. Send via WebSocket (Best Effort, Immediate) h.hub.SendToUser(receiverID.String(), rtPayload) - // 2. Send via Push Notification (Background, Reliable) - // We run this in a goroutine to not block the REST response time, - // but strictly AFTER DB persistence is confirmed. - go func(recipID string, m *models.EncryptedMessage) { - defer func() { - if r := recover(); r != nil { - log.Error().Interface("panic", r).Str("user_id", recipID).Msg("Push notification panic recovered") - } - }() - - err := h.pushService.SendPush(context.Background(), recipID, "New Message", "You have a new secure message", map[string]string{ - "type": "new_message", - "conversation_id": m.ConversationID.String(), - "encrypted": "true", - }) - if err != nil { - log.Warn().Err(err).Str("user_id", recipID).Msg("Failed to send push notification") - } - }(receiverID.String(), msg) + // 2. Send via Notification Service (Background, Reliable) + if h.notificationService != nil { + go func(recipID string, senderID string, convID string) { + _ = h.notificationService.NotifyMessage(context.Background(), recipID, senderID, convID) + }(receiverID.String(), senderID.String(), msg.ConversationID.String()) + } c.JSON(http.StatusCreated, msg) } diff --git a/go-backend/internal/handlers/discover_handler.go b/go-backend/internal/handlers/discover_handler.go index c39e493..4f696c8 100644 --- a/go-backend/internal/handlers/discover_handler.go +++ b/go-backend/internal/handlers/discover_handler.go @@ -131,7 +131,7 @@ func (h *DiscoverHandler) Search(c *gin.Context) { go func() { defer wg.Done() var err error - users, err = h.userRepo.SearchUsers(ctx, query, 10) + users, err = h.userRepo.SearchUsers(ctx, query, viewerID, 10) if err != nil { log.Warn().Err(err).Msg("Failed to search users") users = []models.Profile{} diff --git a/go-backend/internal/handlers/notification_handler.go b/go-backend/internal/handlers/notification_handler.go index 42df9dd..02c7eac 100644 --- a/go-backend/internal/handlers/notification_handler.go +++ b/go-backend/internal/handlers/notification_handler.go @@ -111,6 +111,75 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"}) return } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// BulkMarkAsRead marks a list of notifications as read +// POST /api/v1/notifications/read +func (h *NotificationHandler) BulkMarkAsRead(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var req struct { + IDs []string `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err := h.notifRepo.MarkNotificationsAsRead(c.Request.Context(), req.IDs, userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications as read"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// Archive archives a list of notifications +// POST /api/v1/notifications/archive +func (h *NotificationHandler) Archive(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var req struct { + IDs []string `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err := h.notifRepo.ArchiveNotifications(c.Request.Context(), req.IDs, userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to archive notifications"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// ArchiveAll archives all unarchived notifications +// POST /api/v1/notifications/archive-all +func (h *NotificationHandler) ArchiveAll(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + err := h.notifRepo.ArchiveAllNotifications(c.Request.Context(), userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to archive all notifications"}) + return + } c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 4850115..7f3aab7 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "net/http" "strings" "time" @@ -107,8 +108,8 @@ func (h *PostHandler) CreateComment(c *gin.Context) { "post_id": postID, "post_type": postType, } - h.notificationService.CreateNotification( - c.Request.Context(), + go h.notificationService.CreateNotification( + context.Background(), rootPost.AuthorID.String(), userIDStr.(string), "comment", @@ -443,7 +444,9 @@ func (h *PostHandler) LikePost(c *gin.Context) { // Send push notification to post author go func() { - post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string)) + // Use Background context because the request context will be cancelled + bgCtx := context.Background() + post, err := h.postRepo.GetPostByID(bgCtx, postID, userIDStr.(string)) if err != nil || post.AuthorID.String() == userIDStr.(string) { return // Don't notify self } @@ -457,7 +460,7 @@ func (h *PostHandler) LikePost(c *gin.Context) { } h.notificationService.NotifyLike( - c.Request.Context(), + bgCtx, post.AuthorID.String(), userIDStr.(string), postID, diff --git a/go-backend/internal/handlers/search_handler.go b/go-backend/internal/handlers/search_handler.go index 23ea2cc..f6ecde8 100644 --- a/go-backend/internal/handlers/search_handler.go +++ b/go-backend/internal/handlers/search_handler.go @@ -59,9 +59,7 @@ func (h *SearchHandler) Search(c *gin.Context) { go func() { defer wg.Done() - // TODO: Fix SearchUsers method - // users, userErr = h.userRepo.SearchUsers(ctx, query, 5) - users = []models.Profile{} + users, userErr = h.userRepo.SearchUsers(ctx, query, viewerID, 5) }() go func() { diff --git a/go-backend/internal/handlers/user_handler.go b/go-backend/internal/handlers/user_handler.go index a43595e..e675d1f 100644 --- a/go-backend/internal/handlers/user_handler.go +++ b/go-backend/internal/handlers/user_handler.go @@ -15,18 +15,18 @@ import ( ) type UserHandler struct { - repo *repository.UserRepository - postRepo *repository.PostRepository - pushService *services.PushService - assetService *services.AssetService + repo *repository.UserRepository + postRepo *repository.PostRepository + notificationService *services.NotificationService + assetService *services.AssetService } -func NewUserHandler(repo *repository.UserRepository, postRepo *repository.PostRepository, pushService *services.PushService, assetService *services.AssetService) *UserHandler { +func NewUserHandler(repo *repository.UserRepository, postRepo *repository.PostRepository, notificationService *services.NotificationService, assetService *services.AssetService) *UserHandler { return &UserHandler{ - repo: repo, - postRepo: postRepo, - pushService: pushService, - assetService: assetService, + repo: repo, + postRepo: postRepo, + notificationService: notificationService, + assetService: assetService, } } @@ -130,23 +130,12 @@ func (h *UserHandler) Follow(c *gin.Context) { return } - // Send Push Notification - go func(targetID string, actorID string, status string) { - message := "You have a new follower!" - msgType := "new_follower" - if status == "pending" { - message = "You have a new follow request!" - msgType = "follow_request" - } - - err := h.pushService.SendPush(context.Background(), targetID, "New Follower", message, map[string]string{ - "type": msgType, - "follower_id": actorID, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to send push notification") - } - }(followingID, followerID.(string), status) + // Send Notification + if h.notificationService != nil { + go func(targetID string, actorID string, isPending bool) { + _ = h.notificationService.NotifyFollow(context.Background(), targetID, actorID, isPending) + }(followingID, followerID.(string), status == "pending") + } c.JSON(http.StatusOK, gin.H{"message": "Follow update successful", "status": status}) } @@ -307,20 +296,16 @@ func (h *UserHandler) AcceptFollowRequest(c *gin.Context) { } // Harmony & Notifications - go func(targetID, actorID string) { - // 1. Update Harmony Scores (Mutual gain) - _ = h.repo.UpdateHarmonyScore(context.Background(), targetID, 2) - _ = h.repo.UpdateHarmonyScore(context.Background(), actorID, 2) + if h.notificationService != nil { + go func(targetID, actorID string) { + // 1. Update Harmony Scores (Mutual gain) + _ = h.repo.UpdateHarmonyScore(context.Background(), targetID, 2) + _ = h.repo.UpdateHarmonyScore(context.Background(), actorID, 2) - // 2. Send Push Notification to requester - err := h.pushService.SendPush(context.Background(), actorID, "Request Accepted", "Your follow request was accepted!", map[string]string{ - "type": "follow_accepted", - "follower_id": targetID, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to send follow acceptance push") - } - }(userIdStr.(string), requesterId) + // 2. Send Notification to requester + _ = h.notificationService.NotifyFollowAccepted(context.Background(), actorID, targetID) + }(userIdStr.(string), requesterId) + } c.JSON(http.StatusOK, gin.H{"message": "Follow request accepted"}) } @@ -448,25 +433,6 @@ func (h *UserHandler) GetBlockedUsers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"users": blocked}) } -func (h *UserHandler) RemoveFCMToken(c *gin.Context) { - userID, _ := c.Get("user_id") - - var input struct { - Token string `json:"token" binding:"required"` - } - - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"}) - return - } - - if err := h.repo.DeleteFCMToken(c.Request.Context(), userID.(string), input.Token); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove token"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Token removed successfully"}) -} func (h *UserHandler) BlockUserByHandle(c *gin.Context) { actorID, _ := c.Get("user_id") actorIP := c.ClientIP() @@ -488,23 +454,18 @@ func (h *UserHandler) BlockUserByHandle(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "User blocked by handle"}) } -func (h *UserHandler) RegisterFCMToken(c *gin.Context) { - userID, _ := c.Get("user_id") - - var input struct { - Token string `json:"token" binding:"required"` - Platform string `json:"platform" binding:"required"` - } - - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Token and platform are required"}) +func (h *UserHandler) GetTrustState(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } - if err := h.repo.UpsertFCMToken(c.Request.Context(), userID.(string), input.Token, input.Platform); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register token"}) + state, err := h.repo.GetTrustState(c.Request.Context(), userIDStr.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch trust state"}) return } - c.JSON(http.StatusOK, gin.H{"message": "Token registered successfully"}) + c.JSON(http.StatusOK, state) } diff --git a/go-backend/internal/repository/notification_repository.go b/go-backend/internal/repository/notification_repository.go index c795f80..8e6174d 100644 --- a/go-backend/internal/repository/notification_repository.go +++ b/go-backend/internal/repository/notification_repository.go @@ -116,7 +116,7 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st 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 - WHERE n.user_id = $1::uuid + WHERE n.user_id = $1::uuid AND n.archived_at IS NULL ORDER BY n.created_at DESC LIMIT $2 OFFSET $3 ` @@ -167,7 +167,7 @@ func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, us 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 - WHERE n.user_id = $1::uuid + WHERE n.user_id = $1::uuid AND n.archived_at IS NULL ) SELECT id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata, @@ -234,6 +234,30 @@ func (r *NotificationRepository) DeleteNotification(ctx context.Context, notific 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() + WHERE id = ANY($1::uuid[]) AND user_id = $2::uuid + `, ids, 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() + WHERE user_id = $1::uuid AND archived_at IS NULL + `, userID) + return err +} + func (r *NotificationRepository) GetUnreadCount(ctx context.Context, userID string) (int, error) { var count int err := r.pool.QueryRow(ctx, ` diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index e6410db..fb4252c 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -723,7 +723,7 @@ func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]mod } func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID string, limit int) ([]models.Post, error) { - searchQuery := "%" + query + "%" + // Using % operator for trigram fuzzy match on body sql := ` SELECT p.id, p.author_id, p.category_id, p.body, @@ -735,24 +735,27 @@ func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID p.created_at, pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url, COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count, - CASE WHEN $4 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $4::uuid) ELSE FALSE END as is_liked + CASE WHEN $3 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $3::uuid) ELSE FALSE END as is_liked FROM public.posts p JOIN public.profiles pr ON p.author_id = pr.id LEFT JOIN public.post_metrics m ON p.id = m.post_id - WHERE (p.body ILIKE $1 OR $2 = ANY(p.tags)) + WHERE ( + p.body % $1 OR p.body ILIKE '%' || $1 || '%' + OR $1 = ANY(p.tags) + ) AND p.deleted_at IS NULL AND p.status = 'active' AND ( - p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END + p.author_id = CASE WHEN $3 != '' THEN $3::uuid ELSE NULL END OR pr.is_private = FALSE OR EXISTS ( SELECT 1 FROM public.follows f - WHERE f.follower_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted' + WHERE f.follower_id = CASE WHEN $3 != '' THEN $3::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted' ) ) - ORDER BY p.created_at DESC - LIMIT $3 + ORDER BY similarity(p.body, $1) DESC, p.created_at DESC + LIMIT $2 ` - rows, err := r.pool.Query(ctx, sql, searchQuery, query, limit, viewerID) + rows, err := r.pool.Query(ctx, sql, query, limit, viewerID) if err != nil { return nil, err } diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index c6b2a40..6686dbb 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -112,18 +112,32 @@ func (r *UserRepository) MarkOnboardingComplete(ctx context.Context, userID stri return err } -func (r *UserRepository) SearchUsers(ctx context.Context, query string, limit int) ([]models.Profile, error) { - searchQuery := "%" + query + "%" +func (r *UserRepository) SearchUsers(ctx context.Context, query string, viewerID string, limit int) ([]models.Profile, error) { + // The % operator uses pg_trgm for fuzzy matching sql := ` - SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, created_at - FROM public.profiles - WHERE (handle ILIKE $1 OR display_name ILIKE $1) + SELECT + p.id, p.handle, p.display_name, p.bio, p.avatar_url, p.origin_country, p.has_completed_onboarding, p.created_at + FROM public.profiles p + LEFT JOIN public.trust_state t ON p.id = t.user_id + WHERE ( + p.handle % $1 OR p.handle ILIKE '%' || $1 || '%' + OR p.display_name % $1 OR p.display_name ILIKE '%' || $1 || '%' + ) + AND ( + p.is_private = FALSE + OR ($2 != '' AND EXISTS ( + SELECT 1 FROM public.follows f + WHERE f.follower_id = $2::uuid AND f.following_id = p.id AND f.status = 'accepted' + )) + OR ($2 != '' AND p.id = $2::uuid) + ) + AND NOT public.has_block_between(p.id, CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END) ORDER BY - CASE WHEN handle ILIKE $2 THEN 0 ELSE 1 END, -- exact match priority (simplified to starts with or contains) - created_at DESC + (similarity(p.handle, $1) + CASE WHEN p.handle ILIKE $1 || '%' THEN 0.5 ELSE 0 END + CASE WHEN COALESCE(t.harmony_score, 0) > 80 THEN 0.3 ELSE 0 END) DESC, + p.created_at DESC LIMIT $3 ` - rows, err := r.pool.Query(ctx, sql, searchQuery, query, limit) + rows, err := r.pool.Query(ctx, sql, query, viewerID, limit) if err != nil { return nil, err } diff --git a/go-backend/internal/services/email_service.go b/go-backend/internal/services/email_service.go index 8c5d064..c5370ff 100644 --- a/go-backend/internal/services/email_service.go +++ b/go-backend/internal/services/email_service.go @@ -51,7 +51,7 @@ type sendPulseIdentity struct { func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error { subject := "Verify your Sojorn account" - verifyURL := fmt.Sprintf("https://api.gosojorn.com/api/v1/auth/verify?token=%s", token) + verifyURL := fmt.Sprintf("https://api.sojorn.net/api/v1/auth/verify?token=%s", token) body := fmt.Sprintf(` @@ -75,7 +75,7 @@ func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) erro
- +

Welcome to Sojorn, %s

@@ -99,7 +99,7 @@ func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) erro func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error { subject := "Reset your Sojorn password" - resetURL := fmt.Sprintf("https://sojorn.com/reset-password?token=%s", token) + resetURL := fmt.Sprintf("https://sojorn.net/reset-password?token=%s", token) body := fmt.Sprintf(`

Reset Password for %s

@@ -198,7 +198,7 @@ func (s *EmailService) sendViaSendPulse(toEmail, toName, subject, htmlBody, text // Determine correct FROM email fromEmail := s.config.SMTPFrom if fromEmail == "" { - fromEmail = "no-reply@gosojorn.com" + fromEmail = "no-reply@sojorn.net" } reqBody := sendPulseEmailRequest{ diff --git a/go-backend/internal/services/push_service.go b/go-backend/internal/services/push_service.go index 975689e..80a797d 100644 --- a/go-backend/internal/services/push_service.go +++ b/go-backend/internal/services/push_service.go @@ -215,14 +215,14 @@ func (s *PushService) handleFailedTokens(ctx context.Context, userID string, tok // buildDeepLink creates a deep link URL from notification data func buildDeepLink(data map[string]string) string { target := data["target"] - baseURL := "https://gosojorn.com" + baseURL := "https://sojorn.net" switch target { case "secure_chat": if convID, ok := data["conversation_id"]; ok { - return fmt.Sprintf("%s/chat/%s", baseURL, convID) + return fmt.Sprintf("%s/secure-chat/%s", baseURL, convID) } - return baseURL + "/chat" + return baseURL + "/secure-chat" case "profile": if followerID, ok := data["follower_id"]; ok { return fmt.Sprintf("%s/u/%s", baseURL, followerID) diff --git a/go-backend/nginx_sojorn.conf b/go-backend/nginx_sojorn.conf index 9076075..05e6abc 100644 --- a/go-backend/nginx_sojorn.conf +++ b/go-backend/nginx_sojorn.conf @@ -1,5 +1,5 @@ server { - server_name gosojorn.com www.gosojorn.com; + server_name sojorn.net www.sojorn.net; root /var/www/sojorn; index index.html; @@ -18,18 +18,18 @@ server { } listen 443 ssl; # managed by Certbot - ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; # managed by Certbot - ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot } server { - if ($host = gosojorn.com) { + if ($host = sojorn.net) { return 301 https://$host$request_uri; } # managed by Certbot listen 80; - server_name gosojorn.com www.gosojorn.com; + server_name sojorn.net www.sojorn.net; return 404; # managed by Certbot }