feat: notification system refinements and api route fixes

This commit is contained in:
Patrick Britton 2026-02-04 10:51:01 -06:00
parent 002f960142
commit 72ae644758
20 changed files with 285 additions and 156 deletions

View file

@ -117,9 +117,9 @@ func main() {
hub := realtime.NewHub() hub := realtime.NewHub()
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret) 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) 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) authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService)
categoryHandler := handlers.NewCategoryHandler(categoryRepo) categoryHandler := handlers.NewCategoryHandler(categoryRepo)
keyHandler := handlers.NewKeyHandler(userRepo) keyHandler := handlers.NewKeyHandler(userRepo)
@ -186,6 +186,7 @@ func main() {
authorized.GET("/profile", userHandler.GetProfile) authorized.GET("/profile", userHandler.GetProfile)
authorized.PATCH("/profile", userHandler.UpdateProfile) authorized.PATCH("/profile", userHandler.UpdateProfile)
authorized.POST("/complete-onboarding", authHandler.CompleteOnboarding) authorized.POST("/complete-onboarding", authHandler.CompleteOnboarding)
authorized.GET("/profile/trust-state", userHandler.GetTrustState)
settings := authorized.Group("/settings") settings := authorized.Group("/settings")
{ {
@ -202,6 +203,7 @@ func main() {
users.POST("/:id/accept", userHandler.AcceptFollowRequest) users.POST("/:id/accept", userHandler.AcceptFollowRequest)
users.DELETE("/:id/reject", userHandler.RejectFollowRequest) users.DELETE("/:id/reject", userHandler.RejectFollowRequest)
users.GET("/requests", userHandler.GetPendingFollowRequests) users.GET("/requests", userHandler.GetPendingFollowRequests)
users.POST("/requests", userHandler.GetPendingFollowRequests)
users.GET("/:id/posts", postHandler.GetProfilePosts) users.GET("/:id/posts", postHandler.GetProfilePosts)
users.GET("/:id/saved", userHandler.GetSavedPosts) users.GET("/:id/saved", userHandler.GetSavedPosts)
users.GET("/me/liked", userHandler.GetLikedPosts) users.GET("/me/liked", userHandler.GetLikedPosts)
@ -210,8 +212,7 @@ func main() {
users.GET("/blocked", userHandler.GetBlockedUsers) users.GET("/blocked", userHandler.GetBlockedUsers)
users.POST("/report", userHandler.ReportUser) users.POST("/report", userHandler.ReportUser)
users.POST("/block_by_handle", userHandler.BlockUserByHandle) users.POST("/block_by_handle", userHandler.BlockUserByHandle)
users.DELETE("/notifications/device", userHandler.RemoveFCMToken)
users.POST("/notifications/device", userHandler.RegisterFCMToken)
} }
authorized.POST("/posts", postHandler.CreatePost) authorized.POST("/posts", postHandler.CreatePost)
@ -296,7 +297,10 @@ func main() {
authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount) authorized.GET("/notifications/unread", notificationHandler.GetUnreadCount)
authorized.GET("/notifications/badge", notificationHandler.GetBadgeCount) authorized.GET("/notifications/badge", notificationHandler.GetBadgeCount)
authorized.PUT("/notifications/:id/read", notificationHandler.MarkAsRead) authorized.PUT("/notifications/:id/read", notificationHandler.MarkAsRead)
authorized.POST("/notifications/read", notificationHandler.BulkMarkAsRead)
authorized.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead) 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.DELETE("/notifications/:id", notificationHandler.DeleteNotification)
authorized.GET("/notifications/preferences", notificationHandler.GetNotificationPreferences) authorized.GET("/notifications/preferences", notificationHandler.GetNotificationPreferences)
authorized.PUT("/notifications/preferences", notificationHandler.UpdateNotificationPreferences) authorized.PUT("/notifications/preferences", notificationHandler.UpdateNotificationPreferences)

View file

@ -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!")
}

View file

@ -23,4 +23,4 @@ services:
ADMIN_EMAIL: "admin@sojorn.com" ADMIN_EMAIL: "admin@sojorn.com"
ADMIN_PASSWORD: "Password123!" ADMIN_PASSWORD: "Password123!"
PUBLIC_URL: "https://gosojorn.com/cms" PUBLIC_URL: "https://sojorn.net/cms"

View file

@ -59,18 +59,18 @@ func LoadConfig() *Config {
SMTPPort: getEnvInt("SMTP_PORT", 587), SMTPPort: getEnvInt("SMTP_PORT", 587),
SMTPUser: getEnv("SMTP_USER", ""), SMTPUser: getEnv("SMTP_USER", ""),
SMTPPass: getEnv("SMTP_PASS", ""), 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", ""), SenderAPIToken: getEnv("SENDER_API_TOKEN", ""),
SendPulseID: getEnv("SENDPULSE_ID", ""), SendPulseID: getEnv("SENDPULSE_ID", ""),
SendPulseSecret: getEnv("SENDPULSE_SECRET", ""), SendPulseSecret: getEnv("SENDPULSE_SECRET", ""),
R2SigningSecret: getEnv("R2_SIGNING_SECRET", ""), R2SigningSecret: getEnv("R2_SIGNING_SECRET", ""),
// Default to the public CDN domain to avoid mixed-content/http defaults. // 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"), FirebaseCredentialsFile: getEnv("FIREBASE_CREDENTIALS_FILE", "firebase-service-account.json"),
R2AccountID: getEnv("R2_ACCOUNT_ID", ""), R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
R2APIToken: getEnv("R2_API_TOKEN", ""), R2APIToken: getEnv("R2_API_TOKEN", ""),
R2ImgDomain: getEnv("R2_IMG_DOMAIN", "img.gosojorn.com"), R2ImgDomain: getEnv("R2_IMG_DOMAIN", "img.sojorn.net"),
R2VidDomain: getEnv("R2_VID_DOMAIN", "quips.gosojorn.com"), R2VidDomain: getEnv("R2_VID_DOMAIN", "quips.sojorn.net"),
R2Endpoint: getEnv("R2_ENDPOINT", ""), R2Endpoint: getEnv("R2_ENDPOINT", ""),
R2AccessKey: getEnv("R2_ACCESS_KEY", ""), R2AccessKey: getEnv("R2_ACCESS_KEY", ""),
R2SecretKey: getEnv("R2_SECRET_KEY", ""), R2SecretKey: getEnv("R2_SECRET_KEY", ""),

View file

@ -1,21 +1,21 @@
-- Fix R2 URLs in profiles -- Fix R2 URLs in profiles
UPDATE 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%'; WHERE avatar_url LIKE '%r2.cloudflarestorage.com%';
UPDATE profiles 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%'; WHERE cover_url LIKE '%r2.cloudflarestorage.com%';
-- Fix R2 URLs in posts -- Fix R2 URLs in posts
UPDATE 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%'; WHERE image_url LIKE '%r2.cloudflarestorage.com%';
UPDATE posts 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%'; WHERE video_url LIKE '%r2.cloudflarestorage.com%';
UPDATE posts 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%'; WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com%';

View file

@ -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);

View file

@ -210,7 +210,7 @@ func (h *AuthHandler) CompleteOnboarding(c *gin.Context) {
func (h *AuthHandler) VerifyEmail(c *gin.Context) { func (h *AuthHandler) VerifyEmail(c *gin.Context) {
rawToken := c.Query("token") rawToken := c.Query("token")
if rawToken == "" { 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 return
} }
@ -219,19 +219,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString) userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
if err != nil { 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 return
} }
if time.Now().After(expiresAt) { if time.Now().After(expiresAt) {
h.repo.DeleteVerificationToken(c.Request.Context(), hashString) 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 return
} }
// Activate user // Activate user
if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil { 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 return
} }
@ -251,7 +251,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
// Cleanup // Cleanup
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString) _ = 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) { func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) {

View file

@ -31,7 +31,7 @@ type CategorySettingRequest struct {
} }
type SetCategorySettingsRequest struct { type SetCategorySettingsRequest struct {
Settings []CategorySettingRequest `json:"settings" binding:"required"` Settings []CategorySettingRequest `json:"settings"`
} }
func (h *CategoryHandler) SetUserCategorySettings(c *gin.Context) { func (h *CategoryHandler) SetUserCategorySettings(c *gin.Context) {

View file

@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/patbritton/sojorn-backend/internal/models"
"github.com/patbritton/sojorn-backend/internal/realtime" "github.com/patbritton/sojorn-backend/internal/realtime"
"github.com/patbritton/sojorn-backend/internal/repository" "github.com/patbritton/sojorn-backend/internal/repository"
"github.com/patbritton/sojorn-backend/internal/services" "github.com/patbritton/sojorn-backend/internal/services"
@ -16,16 +15,16 @@ import (
) )
type ChatHandler struct { type ChatHandler struct {
chatRepo *repository.ChatRepository chatRepo *repository.ChatRepository
pushService *services.PushService notificationService *services.NotificationService
hub *realtime.Hub 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{ return &ChatHandler{
chatRepo: chatRepo, chatRepo: chatRepo,
pushService: pushService, notificationService: notificationService,
hub: hub, hub: hub,
} }
} }
@ -143,25 +142,12 @@ func (h *ChatHandler) SendMessage(c *gin.Context) {
// 1. Send via WebSocket (Best Effort, Immediate) // 1. Send via WebSocket (Best Effort, Immediate)
h.hub.SendToUser(receiverID.String(), rtPayload) h.hub.SendToUser(receiverID.String(), rtPayload)
// 2. Send via Push Notification (Background, Reliable) // 2. Send via Notification Service (Background, Reliable)
// We run this in a goroutine to not block the REST response time, if h.notificationService != nil {
// but strictly AFTER DB persistence is confirmed. go func(recipID string, senderID string, convID string) {
go func(recipID string, m *models.EncryptedMessage) { _ = h.notificationService.NotifyMessage(context.Background(), recipID, senderID, convID)
defer func() { }(receiverID.String(), senderID.String(), msg.ConversationID.String())
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)
c.JSON(http.StatusCreated, msg) c.JSON(http.StatusCreated, msg)
} }

View file

@ -131,7 +131,7 @@ func (h *DiscoverHandler) Search(c *gin.Context) {
go func() { go func() {
defer wg.Done() defer wg.Done()
var err error var err error
users, err = h.userRepo.SearchUsers(ctx, query, 10) users, err = h.userRepo.SearchUsers(ctx, query, viewerID, 10)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to search users") log.Warn().Err(err).Msg("Failed to search users")
users = []models.Profile{} users = []models.Profile{}

View file

@ -111,6 +111,75 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
return 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}) c.JSON(http.StatusOK, gin.H{"success": true})
} }

View file

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -107,8 +108,8 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
"post_id": postID, "post_id": postID,
"post_type": postType, "post_type": postType,
} }
h.notificationService.CreateNotification( go h.notificationService.CreateNotification(
c.Request.Context(), context.Background(),
rootPost.AuthorID.String(), rootPost.AuthorID.String(),
userIDStr.(string), userIDStr.(string),
"comment", "comment",
@ -443,7 +444,9 @@ func (h *PostHandler) LikePost(c *gin.Context) {
// Send push notification to post author // Send push notification to post author
go func() { 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) { if err != nil || post.AuthorID.String() == userIDStr.(string) {
return // Don't notify self return // Don't notify self
} }
@ -457,7 +460,7 @@ func (h *PostHandler) LikePost(c *gin.Context) {
} }
h.notificationService.NotifyLike( h.notificationService.NotifyLike(
c.Request.Context(), bgCtx,
post.AuthorID.String(), post.AuthorID.String(),
userIDStr.(string), userIDStr.(string),
postID, postID,

View file

@ -59,9 +59,7 @@ func (h *SearchHandler) Search(c *gin.Context) {
go func() { go func() {
defer wg.Done() defer wg.Done()
// TODO: Fix SearchUsers method users, userErr = h.userRepo.SearchUsers(ctx, query, viewerID, 5)
// users, userErr = h.userRepo.SearchUsers(ctx, query, 5)
users = []models.Profile{}
}() }()
go func() { go func() {

View file

@ -15,18 +15,18 @@ import (
) )
type UserHandler struct { type UserHandler struct {
repo *repository.UserRepository repo *repository.UserRepository
postRepo *repository.PostRepository postRepo *repository.PostRepository
pushService *services.PushService notificationService *services.NotificationService
assetService *services.AssetService 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{ return &UserHandler{
repo: repo, repo: repo,
postRepo: postRepo, postRepo: postRepo,
pushService: pushService, notificationService: notificationService,
assetService: assetService, assetService: assetService,
} }
} }
@ -130,23 +130,12 @@ func (h *UserHandler) Follow(c *gin.Context) {
return return
} }
// Send Push Notification // Send Notification
go func(targetID string, actorID string, status string) { if h.notificationService != nil {
message := "You have a new follower!" go func(targetID string, actorID string, isPending bool) {
msgType := "new_follower" _ = h.notificationService.NotifyFollow(context.Background(), targetID, actorID, isPending)
if status == "pending" { }(followingID, followerID.(string), 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)
c.JSON(http.StatusOK, gin.H{"message": "Follow update successful", "status": status}) 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 // Harmony & Notifications
go func(targetID, actorID string) { if h.notificationService != nil {
// 1. Update Harmony Scores (Mutual gain) go func(targetID, actorID string) {
_ = h.repo.UpdateHarmonyScore(context.Background(), targetID, 2) // 1. Update Harmony Scores (Mutual gain)
_ = h.repo.UpdateHarmonyScore(context.Background(), actorID, 2) _ = h.repo.UpdateHarmonyScore(context.Background(), targetID, 2)
_ = h.repo.UpdateHarmonyScore(context.Background(), actorID, 2)
// 2. Send Push Notification to requester // 2. Send Notification to requester
err := h.pushService.SendPush(context.Background(), actorID, "Request Accepted", "Your follow request was accepted!", map[string]string{ _ = h.notificationService.NotifyFollowAccepted(context.Background(), actorID, targetID)
"type": "follow_accepted", }(userIdStr.(string), requesterId)
"follower_id": targetID, }
})
if err != nil {
log.Error().Err(err).Msg("Failed to send follow acceptance push")
}
}(userIdStr.(string), requesterId)
c.JSON(http.StatusOK, gin.H{"message": "Follow request accepted"}) 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}) 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) { func (h *UserHandler) BlockUserByHandle(c *gin.Context) {
actorID, _ := c.Get("user_id") actorID, _ := c.Get("user_id")
actorIP := c.ClientIP() 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"}) c.JSON(http.StatusOK, gin.H{"message": "User blocked by handle"})
} }
func (h *UserHandler) RegisterFCMToken(c *gin.Context) { func (h *UserHandler) GetTrustState(c *gin.Context) {
userID, _ := c.Get("user_id") userIDStr, exists := c.Get("user_id")
if !exists {
var input struct { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
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"})
return return
} }
if err := h.repo.UpsertFCMToken(c.Request.Context(), userID.(string), input.Token, input.Platform); err != nil { state, err := h.repo.GetTrustState(c.Request.Context(), userIDStr.(string))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register token"}) if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch trust state"})
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "Token registered successfully"}) c.JSON(http.StatusOK, state)
} }

View file

@ -116,7 +116,7 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st
FROM public.notifications n FROM public.notifications n
JOIN public.profiles pr ON n.actor_id = pr.id JOIN public.profiles pr ON n.actor_id = pr.id
LEFT JOIN public.posts po ON n.post_id = po.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 ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
` `
@ -167,7 +167,7 @@ func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, us
FROM public.notifications n FROM public.notifications n
JOIN public.profiles pr ON n.actor_id = pr.id JOIN public.profiles pr ON n.actor_id = pr.id
LEFT JOIN public.posts po ON n.post_id = po.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 SELECT
id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata, 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 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) { func (r *NotificationRepository) GetUnreadCount(ctx context.Context, userID string) (int, error) {
var count int var count int
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `

View file

@ -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) { 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 := ` sql := `
SELECT SELECT
p.id, p.author_id, p.category_id, p.body, 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, p.created_at,
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url, 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, 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 FROM public.posts p
JOIN public.profiles pr ON p.author_id = pr.id JOIN public.profiles pr ON p.author_id = pr.id
LEFT JOIN public.post_metrics m ON p.id = m.post_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.deleted_at IS NULL AND p.status = 'active'
AND ( 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 pr.is_private = FALSE
OR EXISTS ( OR EXISTS (
SELECT 1 FROM public.follows f 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 ORDER BY similarity(p.body, $1) DESC, p.created_at DESC
LIMIT $3 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -112,18 +112,32 @@ func (r *UserRepository) MarkOnboardingComplete(ctx context.Context, userID stri
return err return err
} }
func (r *UserRepository) SearchUsers(ctx context.Context, query string, limit int) ([]models.Profile, error) { func (r *UserRepository) SearchUsers(ctx context.Context, query string, viewerID string, limit int) ([]models.Profile, error) {
searchQuery := "%" + query + "%" // The % operator uses pg_trgm for fuzzy matching
sql := ` sql := `
SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, created_at SELECT
FROM public.profiles p.id, p.handle, p.display_name, p.bio, p.avatar_url, p.origin_country, p.has_completed_onboarding, p.created_at
WHERE (handle ILIKE $1 OR display_name ILIKE $1) 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 ORDER BY
CASE WHEN handle ILIKE $2 THEN 0 ELSE 1 END, -- exact match priority (simplified to starts with or contains) (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,
created_at DESC p.created_at DESC
LIMIT $3 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -51,7 +51,7 @@ type sendPulseIdentity struct {
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error { func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
subject := "Verify your Sojorn account" 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(` body := fmt.Sprintf(`
<!DOCTYPE html> <!DOCTYPE html>
@ -75,7 +75,7 @@ func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) erro
<div class="wrapper"> <div class="wrapper">
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<img src="https://gosojorn.com/web.png" alt="Sojorn" class="logo"> <img src="https://sojorn.net/web.png" alt="Sojorn" class="logo">
</div> </div>
<div class="content"> <div class="content">
<h1>Welcome to Sojorn, %s</h1> <h1>Welcome to Sojorn, %s</h1>
@ -99,7 +99,7 @@ func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) erro
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error { func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
subject := "Reset your Sojorn password" 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(` body := fmt.Sprintf(`
<h2>Reset Password for %s</h2> <h2>Reset Password for %s</h2>
@ -198,7 +198,7 @@ func (s *EmailService) sendViaSendPulse(toEmail, toName, subject, htmlBody, text
// Determine correct FROM email // Determine correct FROM email
fromEmail := s.config.SMTPFrom fromEmail := s.config.SMTPFrom
if fromEmail == "" { if fromEmail == "" {
fromEmail = "no-reply@gosojorn.com" fromEmail = "no-reply@sojorn.net"
} }
reqBody := sendPulseEmailRequest{ reqBody := sendPulseEmailRequest{

View file

@ -215,14 +215,14 @@ func (s *PushService) handleFailedTokens(ctx context.Context, userID string, tok
// buildDeepLink creates a deep link URL from notification data // buildDeepLink creates a deep link URL from notification data
func buildDeepLink(data map[string]string) string { func buildDeepLink(data map[string]string) string {
target := data["target"] target := data["target"]
baseURL := "https://gosojorn.com" baseURL := "https://sojorn.net"
switch target { switch target {
case "secure_chat": case "secure_chat":
if convID, ok := data["conversation_id"]; ok { 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": case "profile":
if followerID, ok := data["follower_id"]; ok { if followerID, ok := data["follower_id"]; ok {
return fmt.Sprintf("%s/u/%s", baseURL, followerID) return fmt.Sprintf("%s/u/%s", baseURL, followerID)

View file

@ -1,5 +1,5 @@
server { server {
server_name gosojorn.com www.gosojorn.com; server_name sojorn.net www.sojorn.net;
root /var/www/sojorn; root /var/www/sojorn;
index index.html; index index.html;
@ -18,18 +18,18 @@ server {
} }
listen 443 ssl; # managed by Certbot listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.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 include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
} }
server { server {
if ($host = gosojorn.com) { if ($host = sojorn.net) {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} # managed by Certbot } # managed by Certbot
listen 80; listen 80;
server_name gosojorn.com www.gosojorn.com; server_name sojorn.net www.sojorn.net;
return 404; # managed by Certbot return 404; # managed by Certbot
} }