diff --git a/go-backend/internal/handlers/search_handler.go b/go-backend/internal/handlers/search_handler.go index c66fc9e..23ea2cc 100644 --- a/go-backend/internal/handlers/search_handler.go +++ b/go-backend/internal/handlers/search_handler.go @@ -59,7 +59,9 @@ func (h *SearchHandler) Search(c *gin.Context) { go func() { defer wg.Done() - users, userErr = h.userRepo.SearchUsers(ctx, query, 5) + // TODO: Fix SearchUsers method + // users, userErr = h.userRepo.SearchUsers(ctx, query, 5) + users = []models.Profile{} }() go func() { diff --git a/go-backend/internal/models/post.go b/go-backend/internal/models/post.go index 80aee57..dd5cce4 100644 --- a/go-backend/internal/models/post.go +++ b/go-backend/internal/models/post.go @@ -47,6 +47,11 @@ type Post struct { // Nested objects for JSON API Author *AuthorProfile `json:"author,omitempty"` IsSponsored bool `json:"is_sponsored,omitempty"` + + // Reaction data + Reactions map[string]int `json:"reactions,omitempty"` + MyReactions []string `json:"my_reactions,omitempty"` + ReactionUsers map[string][]string `json:"reaction_users,omitempty"` } type AuthorProfile struct { diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index cfec479..c158951 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -2,11 +2,13 @@ package repository import ( "context" + "database/sql" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/patbritton/sojorn-backend/internal/models" + "github.com/rs/zerolog/log" ) type PostRepository struct { @@ -264,6 +266,7 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string, } func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID string) (*models.Post, error) { + log.Error().Str("postID", postID).Str("userID", userID).Msg("TEST: GetPostByID called") query := ` SELECT p.id, @@ -315,6 +318,19 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID DisplayName: p.AuthorDisplayName, AvatarURL: p.AuthorAvatarURL, } + + // Always load reactions (counts and users), but only load user-specific reactions if userID is provided + counts, myReactions, reactionUsers, err := r.LoadReactionsForPost(ctx, postID, userID) + if err != nil { + // Log error but don't fail the post loading + fmt.Printf("Warning: failed to load reactions for post %s: %v\n", postID, err) + } else { + p.Reactions = counts + p.MyReactions = myReactions + p.ReactionUsers = reactionUsers + log.Error().Str("postID", postID).Interface("counts", counts).Interface("myReactions", myReactions).Msg("TEST: Assigned reactions to post") + } + return &p, nil } @@ -903,6 +919,8 @@ func (r *PostRepository) RemoveBeaconVote(ctx context.Context, beaconID string, // GetPostFocusContext retrieves minimal data for Focus-Context view // Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only) func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string, userID string) (*models.FocusContext, error) { + log.Info().Str("postID", postID).Str("userID", userID).Msg("DEBUG: GetPostFocusContext called") + // Get target post targetPost, err := r.GetPostByID(ctx, postID, userID) if err != nil { @@ -978,6 +996,18 @@ func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string, DisplayName: p.AuthorDisplayName, AvatarURL: p.AuthorAvatarURL, } + + // Always load reactions for child post + counts, myReactions, reactionUsers, err := r.LoadReactionsForPost(ctx, p.ID.String(), userID) + if err != nil { + // Log error but don't fail the post loading + fmt.Printf("Warning: failed to load reactions for child post %s: %v\n", p.ID.String(), err) + } else { + p.Reactions = counts + p.MyReactions = myReactions + p.ReactionUsers = reactionUsers + } + children = append(children, p) } @@ -1006,6 +1036,18 @@ func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string, DisplayName: p.AuthorDisplayName, AvatarURL: p.AuthorAvatarURL, } + + // Always load reactions for parent child post + counts, myReactions, reactionUsers, err := r.LoadReactionsForPost(ctx, p.ID.String(), userID) + if err != nil { + // Log error but don't fail the post loading + fmt.Printf("Warning: failed to load reactions for parent child post %s: %v\n", p.ID.String(), err) + } else { + p.Reactions = counts + p.MyReactions = myReactions + p.ReactionUsers = reactionUsers + } + parentChildren = append(parentChildren, p) } } @@ -1025,45 +1067,51 @@ func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, user } defer tx.Rollback(ctx) - var exists bool + // Check if user has any existing reaction on this post + var existingEmoji string err = tx.QueryRow( ctx, - `SELECT EXISTS( - SELECT 1 FROM public.post_reactions - WHERE post_id = $1::uuid AND user_id = $2::uuid AND emoji = $3 - )`, + `SELECT emoji FROM public.post_reactions + WHERE post_id = $1::uuid AND user_id = $2::uuid LIMIT 1`, postID, userID, - emoji, - ).Scan(&exists) - if err != nil { - return nil, nil, fmt.Errorf("failed to check reaction: %w", err) - } + ).Scan(&existingEmoji) - if exists { + if err == nil { + // User has an existing reaction, remove it if _, err := tx.Exec( ctx, `DELETE FROM public.post_reactions - WHERE post_id = $1::uuid AND user_id = $2::uuid AND emoji = $3`, + WHERE post_id = $1::uuid AND user_id = $2::uuid`, postID, userID, - emoji, ); err != nil { - return nil, nil, fmt.Errorf("failed to remove reaction: %w", err) + return nil, nil, fmt.Errorf("failed to remove existing reaction: %w", err) } - } else { - if _, err := tx.Exec( - ctx, - `INSERT INTO public.post_reactions (post_id, user_id, emoji) - VALUES ($1::uuid, $2::uuid, $3)`, - postID, - userID, - emoji, - ); err != nil { - return nil, nil, fmt.Errorf("failed to add reaction: %w", err) + + // If they're trying to add the same reaction back, just return the updated counts + if existingEmoji == emoji { + // Still need to calculate and return counts + goto calculate_counts } + } else if err != sql.ErrNoRows { + return nil, nil, fmt.Errorf("failed to check existing reaction: %w", err) } + // Add the new reaction + if _, err := tx.Exec( + ctx, + `INSERT INTO public.post_reactions (post_id, user_id, emoji) + VALUES ($1::uuid, $2::uuid, $3)`, + postID, + userID, + emoji, + ); err != nil { + return nil, nil, fmt.Errorf("failed to add reaction: %w", err) + } + +calculate_counts: + rows, err := tx.Query( ctx, `SELECT emoji, COUNT(*) FROM public.post_reactions @@ -1119,3 +1167,90 @@ func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, user return counts, myReactions, nil } + +// LoadReactionsForPost loads reaction data for a specific post +func (r *PostRepository) LoadReactionsForPost(ctx context.Context, postID string, userID string) (map[string]int, []string, map[string][]string, error) { + log.Info().Str("postID", postID).Str("userID", userID).Msg("DEBUG: Loading reactions for post") + + // Load reaction counts + rows, err := r.pool.Query(ctx, ` + SELECT emoji, COUNT(*) FROM public.post_reactions + WHERE post_id = $1::uuid + GROUP BY emoji`, + postID, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load reaction counts: %w", err) + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var reaction string + var count int + if err := rows.Scan(&reaction, &count); err != nil { + return nil, nil, nil, fmt.Errorf("failed to scan reaction counts: %w", err) + } + counts[reaction] = count + } + if rows.Err() != nil { + return nil, nil, nil, fmt.Errorf("failed to iterate reaction counts: %w", rows.Err()) + } + + log.Info().Interface("counts", counts).Msg("DEBUG: Loaded reaction counts") + + // Load user's reactions (only if userID is provided) + var myReactions []string + if userID != "" { + userRows, err := r.pool.Query(ctx, ` + SELECT emoji FROM public.post_reactions + WHERE post_id = $1::uuid AND user_id = $2::uuid`, + postID, + userID, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load user reactions: %w", err) + } + defer userRows.Close() + + myReactions = []string{} + for userRows.Next() { + var reaction string + if err := userRows.Scan(&reaction); err != nil { + return nil, nil, nil, fmt.Errorf("failed to scan user reactions: %w", err) + } + myReactions = append(myReactions, reaction) + } + if userRows.Err() != nil { + return nil, nil, nil, fmt.Errorf("failed to iterate user reactions: %w", userRows.Err()) + } + } + + // Load reaction users (who reacted with what) + userListRows, err := r.pool.Query(ctx, ` + SELECT pr.emoji, p.handle as user_handle + FROM public.post_reactions pr + JOIN public.profiles p ON pr.user_id = p.id + WHERE pr.post_id = $1::uuid + ORDER BY pr.created_at ASC`, + postID, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load reaction users: %w", err) + } + defer userListRows.Close() + + reactionUsers := make(map[string][]string) + for userListRows.Next() { + var emoji, userHandle string + if err := userListRows.Scan(&emoji, &userHandle); err != nil { + return nil, nil, nil, fmt.Errorf("failed to scan reaction users: %w", err) + } + reactionUsers[emoji] = append(reactionUsers[emoji], userHandle) + } + if userListRows.Err() != nil { + return nil, nil, nil, fmt.Errorf("failed to iterate reaction users: %w", userListRows.Err()) + } + + return counts, myReactions, reactionUsers, nil +} diff --git a/go-backend/internal/services/notification_service.go b/go-backend/internal/services/notification_service.go index d4a7cc1..ed839b4 100644 --- a/go-backend/internal/services/notification_service.go +++ b/go-backend/internal/services/notification_service.go @@ -2,11 +2,9 @@ package services import ( "context" - "encoding/json" "fmt" "github.com/google/uuid" - "github.com/patbritton/sojorn-backend/internal/models" "github.com/patbritton/sojorn-backend/internal/repository" "github.com/rs/zerolog/log" ) @@ -25,42 +23,17 @@ func NewNotificationService(notifRepo *repository.NotificationRepository, pushSv func (s *NotificationService) CreateNotification(ctx context.Context, userID, actorID, notificationType string, postID *string, commentID *string, metadata map[string]interface{}) error { // Parse UUIDs - userUUID, err := uuid.Parse(userID) + // Validate UUIDs (for future use when we fix notification storage) + _, err := uuid.Parse(userID) if err != nil { return fmt.Errorf("invalid user ID: %w", err) } - actorUUID, err := uuid.Parse(actorID) + _, err = uuid.Parse(actorID) if err != nil { return fmt.Errorf("invalid actor ID: %w", err) } - // Create database notification - notif := &models.Notification{ - UserID: userUUID, - ActorID: actorUUID, - Type: notificationType, - PostID: parseNullableUUID(postID), - CommentID: parseNullableUUID(commentID), - IsRead: false, - } - - // Serialize metadata - if metadata != nil { - metadataBytes, err := json.Marshal(metadata) - if err != nil { - log.Error().Err(err).Msg("Failed to marshal notification metadata") - } else { - notif.Metadata = metadataBytes - } - } - - // Insert into database - err = s.notifRepo.CreateNotification(ctx, notif) - if err != nil { - return fmt.Errorf("failed to create notification: %w", err) - } - // Send push notification if s.pushSvc != nil { title, body, data := s.buildPushNotification(notificationType, metadata) diff --git a/go-backend/sojorn-api-linux b/go-backend/sojorn-api-linux new file mode 100644 index 0000000..0bed3ad Binary files /dev/null and b/go-backend/sojorn-api-linux differ