feat: notification system refinements and api route fixes
This commit is contained in:
parent
002f960142
commit
72ae644758
|
|
@ -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)
|
||||
|
|
|
|||
54
go-backend/cmd/search_migration/main.go
Normal file
54
go-backend/cmd/search_migration/main.go
Normal 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!")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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%';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
<!DOCTYPE html>
|
||||
|
|
@ -75,7 +75,7 @@ func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) erro
|
|||
<div class="wrapper">
|
||||
<div class="container">
|
||||
<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 class="content">
|
||||
<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 {
|
||||
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(`
|
||||
<h2>Reset Password for %s</h2>
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue