diff --git a/_tmp_server/main.go b/_tmp_server/main.go
new file mode 100644
index 0000000..8a3386c
--- /dev/null
+++ b/_tmp_server/main.go
@@ -0,0 +1,309 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ aws "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/gin-contrib/cors"
+ "github.com/gin-gonic/gin"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/patbritton/sojorn-backend/internal/config"
+ "github.com/patbritton/sojorn-backend/internal/handlers"
+ "github.com/patbritton/sojorn-backend/internal/middleware"
+ "github.com/patbritton/sojorn-backend/internal/realtime"
+ "github.com/patbritton/sojorn-backend/internal/repository"
+ "github.com/patbritton/sojorn-backend/internal/services"
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+func main() {
+ // Load Config
+ cfg := config.LoadConfig()
+
+ // Logger setup
+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
+
+ // Database Connection
+ // Check if DATABASE_URL is set, if not try to load from .env
+ if cfg.DatabaseURL == "" {
+ log.Fatal().Msg("DATABASE_URL is not set")
+ }
+
+ pgxConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Unable to parse database config")
+ }
+
+ dbPool, err := pgxpool.NewWithConfig(context.Background(), pgxConfig)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Unable to connect to database")
+ }
+ defer dbPool.Close()
+
+ if err := dbPool.Ping(context.Background()); err != nil {
+ log.Fatal().Err(err).Msg("Unable to ping database")
+ }
+
+ // Initialize Gin
+ r := gin.Default()
+
+ allowedOrigins := strings.Split(cfg.CORSOrigins, ",")
+ allowAllOrigins := false
+ allowedOriginSet := make(map[string]struct{}, len(allowedOrigins))
+ for _, origin := range allowedOrigins {
+ trimmed := strings.TrimSpace(origin)
+ if trimmed == "" {
+ continue
+ }
+ if trimmed == "*" {
+ allowAllOrigins = true
+ break
+ }
+ allowedOriginSet[trimmed] = struct{}{}
+ }
+
+ // Use CORS middleware
+ r.Use(cors.New(cors.Config{
+ AllowOriginFunc: func(origin string) bool {
+ log.Debug().Msgf("CORS origin: %s", origin)
+ if allowAllOrigins {
+ return true
+ }
+ // Always allow localhost/loopback for dev tools & Flutter web debug
+ if strings.HasPrefix(origin, "http://localhost") ||
+ strings.HasPrefix(origin, "https://localhost") ||
+ strings.HasPrefix(origin, "http://127.0.0.1") ||
+ strings.HasPrefix(origin, "https://127.0.0.1") {
+ return true
+ }
+ _, ok := allowedOriginSet[origin]
+ return ok
+ },
+ AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
+ AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
+ ExposeHeaders: []string{"Content-Length"},
+ AllowCredentials: true,
+ MaxAge: 12 * time.Hour,
+ }))
+
+ r.NoRoute(func(c *gin.Context) {
+ log.Debug().Msgf("No route found for %s %s", c.Request.Method, c.Request.URL.Path)
+ c.JSON(404, gin.H{"error": "route not found", "path": c.Request.URL.Path, "method": c.Request.Method})
+ })
+
+ // Initialize Repositories
+ userRepo := repository.NewUserRepository(dbPool)
+ postRepo := repository.NewPostRepository(dbPool)
+ chatRepo := repository.NewChatRepository(dbPool)
+ categoryRepo := repository.NewCategoryRepository(dbPool)
+ notifRepo := repository.NewNotificationRepository(dbPool)
+
+ // Initialize Services
+ assetService := services.NewAssetService(cfg.R2SigningSecret, cfg.R2PublicBaseURL, cfg.R2ImgDomain, cfg.R2VidDomain)
+ feedService := services.NewFeedService(postRepo, assetService)
+
+ pushService, err := services.NewPushService(userRepo, cfg.FirebaseCredentialsFile)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to initialize PushService")
+ }
+
+ emailService := services.NewEmailService(cfg)
+
+ // Initialize Realtime
+ hub := realtime.NewHub()
+ jwtSecrets := []string{cfg.JWTSecret}
+ if cfg.SecondaryJWTSecret != "" {
+ jwtSecrets = append(jwtSecrets, cfg.SecondaryJWTSecret)
+ }
+ wsHandler := handlers.NewWSHandler(hub, jwtSecrets)
+
+ // Initialize Handlers
+ userHandler := handlers.NewUserHandler(userRepo, postRepo, pushService, assetService)
+ postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService)
+ chatHandler := handlers.NewChatHandler(chatRepo, pushService, hub)
+ authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService)
+ categoryHandler := handlers.NewCategoryHandler(categoryRepo)
+ keyHandler := handlers.NewKeyHandler(userRepo)
+ functionProxyHandler := handlers.NewFunctionProxyHandler()
+ settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
+ analysisHandler := handlers.NewAnalysisHandler()
+
+ // Setup Media Handler (R2)
+ var s3Client *s3.Client
+ if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
+ resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
+ return aws.Endpoint{URL: cfg.R2Endpoint, PartitionID: "aws", SigningRegion: "auto"}, nil
+ })
+ awsCfg, err := awsconfig.LoadDefaultConfig(
+ context.Background(),
+ awsconfig.WithRegion("auto"),
+ awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.R2AccessKey, cfg.R2SecretKey, "")),
+ awsconfig.WithEndpointResolverWithOptions(resolver),
+ )
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to load AWS/R2 config, falling back to R2 API token flow")
+ } else {
+ s3Client = s3.NewFromConfig(awsCfg)
+ }
+ }
+
+ mediaHandler := handlers.NewMediaHandler(
+ s3Client,
+ cfg.R2AccountID,
+ cfg.R2APIToken,
+ cfg.R2MediaBucket,
+ cfg.R2VideoBucket,
+ cfg.R2ImgDomain,
+ cfg.R2VidDomain,
+ )
+
+ // WebSocket Route
+ r.GET("/ws", wsHandler.ServeWS)
+
+ // API Groups
+ r.GET("/ping", func(c *gin.Context) {
+ c.String(200, "pong")
+ })
+
+ // Liveness/healthcheck endpoint (no auth)
+ r.GET("/health", func(c *gin.Context) {
+ c.JSON(200, gin.H{"status": "ok"})
+ })
+
+ v1 := r.Group("/api/v1")
+ {
+ // Public routes
+ log.Info().Msg("Registering public auth routes")
+ auth := v1.Group("/auth")
+ auth.Use(middleware.RateLimit(0.5, 3)) // 3 requests bust, then 1 every 2 seconds
+ {
+ auth.POST("/register", authHandler.Register)
+ auth.POST("/signup", authHandler.Register) // Alias for Supabase compatibility/legacy
+ auth.POST("/login", authHandler.Login)
+ auth.POST("/refresh", authHandler.RefreshSession) // Added
+ auth.POST("/resend-verification", authHandler.ResendVerificationEmail)
+ auth.GET("/verify", authHandler.VerifyEmail)
+ auth.POST("/forgot-password", authHandler.ForgotPassword)
+ auth.POST("/reset-password", authHandler.ResetPassword)
+ }
+
+ // Authenticated routes
+ authorized := v1.Group("")
+ authorized.Use(middleware.AuthMiddleware(jwtSecrets))
+ {
+ authorized.GET("/profiles/:id", userHandler.GetProfile)
+ authorized.GET("/profile", userHandler.GetProfile)
+ authorized.PATCH("/profile", userHandler.UpdateProfile)
+ authorized.POST("/complete-onboarding", authHandler.CompleteOnboarding)
+
+ // Settings Routes
+ settings := authorized.Group("/settings")
+ {
+ settings.GET("/privacy", settingsHandler.GetPrivacySettings)
+ settings.PATCH("/privacy", settingsHandler.UpdatePrivacySettings)
+ settings.GET("/user", settingsHandler.GetUserSettings)
+ settings.PATCH("/user", settingsHandler.UpdateUserSettings)
+ }
+
+ users := authorized.Group("/users")
+ {
+ users.POST("/:id/follow", userHandler.Follow)
+ users.DELETE("/:id/follow", userHandler.Unfollow)
+ users.POST("/:id/accept", userHandler.AcceptFollowRequest)
+ users.DELETE("/:id/reject", userHandler.RejectFollowRequest)
+ users.GET("/requests", userHandler.GetPendingFollowRequests) // Or /me/requests
+ users.GET("/:id/posts", postHandler.GetProfilePosts)
+ // Interaction Lists
+ users.GET("/me/saved", userHandler.GetSavedPosts)
+ users.GET("/me/liked", userHandler.GetLikedPosts)
+ }
+
+ authorized.POST("/posts", postHandler.CreatePost)
+ authorized.GET("/posts/:id", postHandler.GetPost)
+ authorized.GET("/posts/:id/chain", postHandler.GetPostChain)
+ authorized.GET("/posts/:id/focus-context", postHandler.GetPostFocusContext)
+ authorized.PATCH("/posts/:id", postHandler.UpdatePost)
+ authorized.DELETE("/posts/:id", postHandler.DeletePost)
+ authorized.POST("/posts/:id/pin", postHandler.PinPost)
+ authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
+ authorized.POST("/posts/:id/like", postHandler.LikePost)
+ authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
+ authorized.POST("/posts/:id/save", postHandler.SavePost)
+ authorized.DELETE("/posts/:id/save", postHandler.UnsavePost)
+ authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
+ authorized.POST("/posts/:id/comments", postHandler.CreateComment)
+ authorized.GET("/feed", postHandler.GetFeed)
+ authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
+ authorized.GET("/categories", categoryHandler.GetCategories)
+ authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings)
+ authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings)
+ authorized.POST("/analysis/tone", analysisHandler.CheckTone)
+
+ // Chat routes
+ authorized.GET("/conversations", chatHandler.GetConversations)
+ authorized.GET("/conversation", chatHandler.GetOrCreateConversation)
+ authorized.POST("/messages", chatHandler.SendMessage)
+ authorized.GET("/conversations/:id/messages", chatHandler.GetMessages)
+ authorized.GET("/mutual-follows", chatHandler.GetMutualFollows)
+
+ // Key routes
+ authorized.POST("/keys", keyHandler.PublishKeys)
+ authorized.GET("/keys/:id", keyHandler.GetKeyBundle)
+
+ // Supabase Function Proxy
+ authorized.Any("/functions/:name", functionProxyHandler.ProxyFunction)
+
+ // Media routes
+ authorized.POST("/upload", mediaHandler.Upload)
+
+ // Search route
+ searchHandler := handlers.NewSearchHandler(userRepo, postRepo, assetService)
+ authorized.GET("/search", searchHandler.Search)
+
+ // Notifications
+ notificationHandler := handlers.NewNotificationHandler(notifRepo)
+ authorized.GET("/notifications", notificationHandler.GetNotifications)
+ authorized.POST("/notifications/device", settingsHandler.RegisterDevice)
+ authorized.DELETE("/notifications/device", settingsHandler.UnregisterDevice)
+ }
+ }
+
+ // Start server
+ srv := &http.Server{
+ Addr: "127.0.0.1:" + cfg.Port,
+ Handler: r,
+ }
+
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatal().Err(err).Msg("Failed to start server")
+ }
+ }()
+
+ log.Info().Msgf("Server started on port %s", cfg.Port)
+
+ // Wait for interrupt signal to gracefully shutdown the server with
+ // a timeout of 5 seconds.
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+ log.Info().Msg("Shutting down server...")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ log.Fatal().Err(err).Msg("Server forced to shutdown")
+ }
+
+ log.Info().Msg("Server exiting")
+}
diff --git a/_tmp_server/post_handler.go b/_tmp_server/post_handler.go
new file mode 100644
index 0000000..0d339a2
--- /dev/null
+++ b/_tmp_server/post_handler.go
@@ -0,0 +1,494 @@
+package handlers
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/patbritton/sojorn-backend/internal/models"
+ "github.com/patbritton/sojorn-backend/internal/repository"
+ "github.com/patbritton/sojorn-backend/internal/services"
+ "github.com/patbritton/sojorn-backend/pkg/utils"
+)
+
+type PostHandler struct {
+ postRepo *repository.PostRepository
+ userRepo *repository.UserRepository
+ feedService *services.FeedService
+ assetService *services.AssetService
+}
+
+func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService) *PostHandler {
+ return &PostHandler{
+ postRepo: postRepo,
+ userRepo: userRepo,
+ feedService: feedService,
+ assetService: assetService,
+ }
+}
+
+func (h *PostHandler) CreateComment(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID, _ := uuid.Parse(userIDStr.(string))
+ postID := c.Param("id")
+
+ var req struct {
+ Body string `json:"body" binding:"required,max=500"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ comment := &models.Comment{
+ PostID: postID,
+ AuthorID: userID,
+ Body: req.Body,
+ Status: "active",
+ }
+
+ if err := h.postRepo.CreateComment(c.Request.Context(), comment); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{"comment": comment})
+}
+
+func (h *PostHandler) GetNearbyBeacons(c *gin.Context) {
+ lat := utils.GetQueryFloat(c, "lat", 0)
+ long := utils.GetQueryFloat(c, "long", 0)
+ radius := utils.GetQueryInt(c, "radius", 16000)
+
+ beacons, err := h.postRepo.GetNearbyBeacons(c.Request.Context(), lat, long, radius)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch nearby beacons", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"beacons": beacons})
+}
+
+func (h *PostHandler) CreatePost(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID, _ := uuid.Parse(userIDStr.(string))
+
+ var req struct {
+ CategoryID *string `json:"category_id"`
+ Body string `json:"body" binding:"required,max=500"`
+ ImageURL *string `json:"image_url"`
+ VideoURL *string `json:"video_url"`
+ Thumbnail *string `json:"thumbnail_url"`
+ DurationMS *int `json:"duration_ms"`
+ IsBeacon bool `json:"is_beacon"`
+ BeaconType *string `json:"beacon_type"`
+ BeaconLat *float64 `json:"beacon_lat"`
+ BeaconLong *float64 `json:"beacon_long"`
+ TTLHours *int `json:"ttl_hours"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // 1. Check rate limit (Simplification)
+ trustState, err := h.userRepo.GetTrustState(c.Request.Context(), userID.String())
+ if err == nil && trustState.PostsToday >= 50 { // Example hard limit
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "Daily post limit reached"})
+ return
+ }
+
+ // 2. Extract tags
+ tags := utils.ExtractHashtags(req.Body)
+
+ // 3. Mock Tone Check (In production, this would call a service or AI model)
+ tone := "neutral"
+ cis := 0.8
+
+ // 4. Resolve TTL
+ var expiresAt *time.Time
+ if req.TTLHours != nil && *req.TTLHours > 0 {
+ t := time.Now().Add(time.Duration(*req.TTLHours) * time.Hour)
+ expiresAt = &t
+ }
+
+ duration := 0
+ if req.DurationMS != nil {
+ duration = *req.DurationMS
+ }
+
+ post := &models.Post{
+ AuthorID: userID,
+ Body: req.Body,
+ Status: "active",
+ ToneLabel: &tone,
+ CISScore: &cis,
+ ImageURL: req.ImageURL,
+ VideoURL: req.VideoURL,
+ ThumbnailURL: req.Thumbnail,
+ DurationMS: duration,
+ BodyFormat: "plain",
+ Tags: tags,
+ IsBeacon: req.IsBeacon,
+ BeaconType: req.BeaconType,
+ Confidence: 0.5, // Initial confidence
+ IsActiveBeacon: req.IsBeacon,
+ AllowChain: !req.IsBeacon,
+ Visibility: "public",
+ ExpiresAt: expiresAt,
+ Lat: req.BeaconLat,
+ Long: req.BeaconLong,
+ }
+
+ if req.CategoryID != nil {
+ catID, _ := uuid.Parse(*req.CategoryID)
+ post.CategoryID = &catID
+ }
+
+ // Create post
+ err = h.postRepo.CreatePost(c.Request.Context(), post)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "post": post,
+ "tags": tags,
+ "tone_analysis": gin.H{
+ "tone": tone,
+ "cis": cis,
+ },
+ })
+}
+
+func (h *PostHandler) GetFeed(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+
+ limit := utils.GetQueryInt(c, "limit", 20)
+ offset := utils.GetQueryInt(c, "offset", 0)
+ category := c.Query("category")
+ hasVideo := c.Query("has_video") == "true"
+
+ posts, err := h.feedService.GetFeed(c.Request.Context(), userIDStr.(string), category, hasVideo, limit, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feed", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"posts": posts})
+}
+
+func (h *PostHandler) GetProfilePosts(c *gin.Context) {
+ authorID := c.Param("id")
+ if authorID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Author ID required"})
+ return
+ }
+
+ limit := utils.GetQueryInt(c, "limit", 20)
+ offset := utils.GetQueryInt(c, "offset", 0)
+
+ viewerID := ""
+ if val, exists := c.Get("user_id"); exists {
+ viewerID = val.(string)
+ }
+
+ posts, err := h.postRepo.GetPostsByAuthor(c.Request.Context(), authorID, viewerID, limit, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile posts", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"posts": posts})
+}
+
+func (h *PostHandler) GetPost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ post, err := h.postRepo.GetPostByID(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"})
+ return
+ }
+
+ // Sign URL
+ if post.ImageURL != nil {
+ signed := h.assetService.SignImageURL(*post.ImageURL)
+ post.ImageURL = &signed
+ }
+ if post.VideoURL != nil {
+ signed := h.assetService.SignVideoURL(*post.VideoURL)
+ post.VideoURL = &signed
+ }
+ if post.ThumbnailURL != nil {
+ signed := h.assetService.SignImageURL(*post.ThumbnailURL)
+ post.ThumbnailURL = &signed
+ }
+
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+func (h *PostHandler) UpdatePost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ var req struct {
+ Body string `json:"body" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ err := h.postRepo.UpdatePost(c.Request.Context(), postID, userIDStr.(string), req.Body)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post updated"})
+}
+
+func (h *PostHandler) DeletePost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ err := h.postRepo.DeletePost(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
+}
+
+func (h *PostHandler) PinPost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ var req struct {
+ Pinned bool `json:"pinned"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ err := h.postRepo.PinPost(c.Request.Context(), postID, userIDStr.(string), req.Pinned)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to pin/unpin post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post pin status updated"})
+}
+
+func (h *PostHandler) UpdateVisibility(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ var req struct {
+ Visibility string `json:"visibility" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ err := h.postRepo.UpdateVisibility(c.Request.Context(), postID, userIDStr.(string), req.Visibility)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update visibility", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post visibility updated"})
+}
+
+func (h *PostHandler) LikePost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ err := h.postRepo.LikePost(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to like post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post liked"})
+}
+
+func (h *PostHandler) UnlikePost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ err := h.postRepo.UnlikePost(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unlike post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
+}
+
+func (h *PostHandler) SavePost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ err := h.postRepo.SavePost(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post saved"})
+}
+
+func (h *PostHandler) UnsavePost(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ err := h.postRepo.UnsavePost(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsave post", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Post unsaved"})
+}
+
+func (h *PostHandler) ToggleReaction(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ var req struct {
+ Emoji string `json:"emoji" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ emoji := strings.TrimSpace(req.Emoji)
+ if emoji == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
+ return
+ }
+
+ counts, myReactions, err := h.postRepo.ToggleReaction(c.Request.Context(), postID, userIDStr.(string), emoji)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle reaction", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "reactions": counts,
+ "my_reactions": myReactions,
+ })
+}
+
+func (h *PostHandler) GetSavedPosts(c *gin.Context) {
+ userID := c.Param("id")
+ if userID == "" || userID == "me" {
+ userIDStr, exists := c.Get("user_id")
+ if exists {
+ userID = userIDStr.(string)
+ }
+ }
+
+ if userID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
+ return
+ }
+
+ limit := utils.GetQueryInt(c, "limit", 20)
+ offset := utils.GetQueryInt(c, "offset", 0)
+
+ posts, err := h.postRepo.GetSavedPosts(c.Request.Context(), userID, limit, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved posts", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"posts": posts})
+}
+
+func (h *PostHandler) GetLikedPosts(c *gin.Context) {
+ userID := c.Param("id")
+ if userID == "" || userID == "me" {
+ userIDStr, exists := c.Get("user_id")
+ if exists {
+ userID = userIDStr.(string)
+ }
+ }
+
+ if userID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "User ID required"})
+ return
+ }
+
+ limit := utils.GetQueryInt(c, "limit", 20)
+ offset := utils.GetQueryInt(c, "offset", 0)
+
+ posts, err := h.postRepo.GetLikedPosts(c.Request.Context(), userID, limit, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch liked posts", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"posts": posts})
+}
+
+func (h *PostHandler) GetPostChain(c *gin.Context) {
+ postID := c.Param("id")
+
+ posts, err := h.postRepo.GetPostChain(c.Request.Context(), postID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch post chain", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"chain": posts})
+}
+
+func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch focus context", "details": err.Error()})
+ return
+ }
+
+ h.signPostMedia(focusContext.TargetPost)
+ h.signPostMedia(focusContext.ParentPost)
+ for i := range focusContext.Children {
+ h.signPostMedia(&focusContext.Children[i])
+ }
+
+ c.JSON(http.StatusOK, focusContext)
+}
+
+func (h *PostHandler) signPostMedia(post *models.Post) {
+ if post == nil {
+ return
+ }
+ if post.ImageURL != nil {
+ signed := h.assetService.SignImageURL(*post.ImageURL)
+ post.ImageURL = &signed
+ }
+ if post.VideoURL != nil {
+ signed := h.assetService.SignVideoURL(*post.VideoURL)
+ post.VideoURL = &signed
+ }
+ if post.ThumbnailURL != nil {
+ signed := h.assetService.SignImageURL(*post.ThumbnailURL)
+ post.ThumbnailURL = &signed
+ }
+}
diff --git a/_tmp_server/post_repository.go b/_tmp_server/post_repository.go
new file mode 100644
index 0000000..4606345
--- /dev/null
+++ b/_tmp_server/post_repository.go
@@ -0,0 +1,912 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/patbritton/sojorn-backend/internal/models"
+)
+
+type PostRepository struct {
+ pool *pgxpool.Pool
+}
+
+func NewPostRepository(pool *pgxpool.Pool) *PostRepository {
+ return &PostRepository{pool: pool}
+}
+
+func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) error {
+ // Calculate confidence score if it's a beacon
+ if post.IsBeacon {
+ var harmonyScore int
+ err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1", post.AuthorID).Scan(&harmonyScore)
+ if err == nil {
+ // Logic: confidence = harmony_score / 100.0 (legacy parity)
+ post.Confidence = float64(harmonyScore) / 100.0
+ } else {
+ post.Confidence = 0.5 // Default fallback
+ }
+ }
+
+ query := `
+ INSERT INTO public.posts (
+ author_id, category_id, body, status, tone_label, cis_score,
+ image_url, video_url, thumbnail_url, duration_ms, body_format, background_id, tags,
+ is_beacon, beacon_type, location, confidence_score,
+ is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
+ $14, $15,
+ CASE WHEN ($16::double precision) IS NOT NULL AND ($17::double precision) IS NOT NULL
+ THEN ST_SetSRID(ST_MakePoint(($17::double precision), ($16::double precision)), 4326)::geography
+ ELSE NULL END,
+ $18, $19, $20, $21, $22, $23
+ ) RETURNING id, created_at
+ `
+
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to start transaction: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ err = tx.QueryRow(ctx, query,
+ post.AuthorID, post.CategoryID, post.Body, post.Status, post.ToneLabel, post.CISScore,
+ post.ImageURL, post.VideoURL, post.ThumbnailURL, post.DurationMS, post.BodyFormat, post.BackgroundID, post.Tags,
+ post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
+ post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
+ ).Scan(&post.ID, &post.CreatedAt)
+
+ if err != nil {
+ return fmt.Errorf("failed to create post: %w", err)
+ }
+
+ // Initialize metrics
+ if _, err := tx.Exec(ctx, "INSERT INTO public.post_metrics (post_id) VALUES ($1)", post.ID); err != nil {
+ return fmt.Errorf("failed to initialize post metrics: %w", err)
+ }
+
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("failed to commit post transaction: %w", err)
+ }
+
+ return nil
+}
+
+func (r *PostRepository) GetRandomSponsoredPost(ctx context.Context, userID string) (*models.Post, error) {
+ query := `
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), COALESCE(p.video_url, ''), COALESCE(p.thumbnail_url, ''), p.duration_ms, COALESCE(p.tags, ARRAY[]::text[]), 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,
+ FALSE as is_liked,
+ sp.advertiser_name
+ FROM public.sponsored_posts sp
+ JOIN public.posts p ON sp.post_id = p.id
+ JOIN public.profiles pr ON p.author_id = pr.id
+ LEFT JOIN public.post_metrics m ON p.id = m.post_id
+ WHERE p.deleted_at IS NULL AND p.status = 'active'
+ AND (
+ p.category_id IS NULL OR EXISTS (
+ SELECT 1 FROM public.user_category_settings ucs
+ WHERE ucs.user_id = $1 AND ucs.category_id = p.category_id AND ucs.enabled = true
+ )
+ )
+ ORDER BY RANDOM()
+ LIMIT 1
+ `
+ var p models.Post
+ var advertiserName string
+ err := r.pool.QueryRow(ctx, query, userID).Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &advertiserName,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: advertiserName, // Display advertiser name for ads
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ p.IsSponsored = true
+ return &p, nil
+}
+
+func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlug string, hasVideo bool, limit int, offset int) ([]models.Post, error) {
+ query := `
+ SELECT
+ p.id,
+ p.author_id,
+ p.category_id,
+ p.body,
+ COALESCE(p.image_url, ''),
+ CASE
+ WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
+ WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
+ ELSE ''
+ END AS resolved_video_url,
+ COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
+ COALESCE(p.duration_ms, 0),
+ COALESCE(p.tags, ARRAY[]::text[]),
+ 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,
+ FALSE 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
+ LEFT JOIN public.categories c ON p.category_id = c.id
+ WHERE p.deleted_at IS NULL
+ AND (
+ p.author_id = $4 -- My own posts
+ OR pr.is_private = FALSE -- Public profiles
+ OR EXISTS (
+ SELECT 1 FROM public.follows f
+ WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
+ )
+ )
+ AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%%.mp4')))
+ ORDER BY p.created_at DESC
+ LIMIT $1 OFFSET $2
+ `
+ rows, err := r.pool.Query(ctx, query, limit, offset, hasVideo, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ posts := []models.Post{}
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ posts = append(posts, p)
+ }
+ return posts, nil
+}
+
+func (r *PostRepository) GetCategories(ctx context.Context) ([]models.Category, error) {
+ query := `SELECT id, slug, name, description, is_sensitive, created_at FROM public.categories ORDER BY name ASC`
+ rows, err := r.pool.Query(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var categories []models.Category
+ for rows.Next() {
+ var c models.Category
+ err := rows.Scan(&c.ID, &c.Slug, &c.Name, &c.Description, &c.IsSensitive, &c.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ categories = append(categories, c)
+ }
+ return categories, nil
+}
+
+func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string, viewerID string, limit int, offset int) ([]models.Post, error) {
+ query := `
+ SELECT
+ p.id,
+ p.author_id,
+ p.category_id,
+ p.body,
+ COALESCE(p.image_url, ''),
+ CASE
+ WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
+ WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
+ ELSE ''
+ END AS resolved_video_url,
+ COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
+ COALESCE(p.duration_ms, 0),
+ COALESCE(p.tags, ARRAY[]::text[]),
+ 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,
+ FALSE as is_liked
+ FROM posts p
+ JOIN profiles pr ON p.author_id = pr.id
+ LEFT JOIN post_metrics m ON p.id = m.post_id
+ WHERE p.author_id = $1 AND p.deleted_at IS NULL AND p.status = 'active'
+ AND (
+ p.author_id = $4 -- Viewer is author
+ OR pr.is_private = FALSE -- Public profile
+ OR EXISTS (
+ SELECT 1 FROM public.follows f
+ WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
+ )
+ )
+ ORDER BY p.created_at DESC
+ LIMIT $2 OFFSET $3
+ `
+ rows, err := r.pool.Query(ctx, query, authorID, limit, offset, viewerID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var posts []models.Post
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ posts = append(posts, p)
+ }
+ return posts, nil
+}
+
+func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID string) (*models.Post, error) {
+ query := `
+ SELECT
+ p.id,
+ p.author_id,
+ p.category_id,
+ p.body,
+ COALESCE(p.image_url, ''),
+ CASE
+ WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
+ WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
+ ELSE ''
+ END AS resolved_video_url,
+ COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
+ p.duration_ms,
+ COALESCE(p.tags, ARRAY[]::text[]),
+ p.created_at,
+ p.chain_parent_id,
+ 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 $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2) ELSE FALSE END as is_liked,
+ p.allow_chain, p.visibility
+ 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.id = $1 AND p.deleted_at IS NULL
+ AND (
+ p.author_id = $2
+ OR pr.is_private = FALSE
+ OR EXISTS (
+ SELECT 1 FROM public.follows f
+ WHERE f.follower_id = $2 AND f.following_id = p.author_id AND f.status = 'accepted'
+ )
+ )
+ `
+ var p models.Post
+ err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.ChainParentID,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &p.AllowChain, &p.Visibility,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ return &p, nil
+}
+
+func (r *PostRepository) UpdatePost(ctx context.Context, postID string, authorID string, body string) error {
+ query := `UPDATE public.posts SET body = $1, edited_at = NOW() WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
+ res, err := r.pool.Exec(ctx, query, body, postID, authorID)
+ if err != nil {
+ return err
+ }
+ if res.RowsAffected() == 0 {
+ return fmt.Errorf("post not found or unauthorized")
+ }
+ return nil
+}
+
+func (r *PostRepository) DeletePost(ctx context.Context, postID string, authorID string) error {
+ query := `UPDATE public.posts SET deleted_at = NOW() WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL`
+ res, err := r.pool.Exec(ctx, query, postID, authorID)
+ if err != nil {
+ return err
+ }
+ if res.RowsAffected() == 0 {
+ return fmt.Errorf("post not found or unauthorized")
+ }
+ return nil
+}
+
+func (r *PostRepository) PinPost(ctx context.Context, postID string, authorID string, pinned bool) error {
+ var val *time.Time
+ if pinned {
+ t := time.Now()
+ val = &t
+ }
+ query := `UPDATE public.posts SET pinned_at = $1 WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
+ res, err := r.pool.Exec(ctx, query, val, postID, authorID)
+ if err != nil {
+ return err
+ }
+ if res.RowsAffected() == 0 {
+ return fmt.Errorf("post not found or unauthorized")
+ }
+ return nil
+}
+
+func (r *PostRepository) UpdateVisibility(ctx context.Context, postID string, authorID string, visibility string) error {
+ query := `UPDATE public.posts SET visibility = $1 WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
+ res, err := r.pool.Exec(ctx, query, visibility, postID, authorID)
+ if err != nil {
+ return err
+ }
+ if res.RowsAffected() == 0 {
+ return fmt.Errorf("post not found or unauthorized")
+ }
+ return nil
+}
+
+func (r *PostRepository) LikePost(ctx context.Context, postID string, userID string) error {
+ query := `INSERT INTO public.post_likes (post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
+ _, err := r.pool.Exec(ctx, query, postID, userID)
+ return err
+}
+
+func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID string) error {
+ query := `DELETE FROM public.post_likes WHERE post_id = $1 AND user_id = $2`
+ _, err := r.pool.Exec(ctx, query, postID, userID)
+ return err
+}
+
+func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
+ query := `INSERT INTO public.post_saves (post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
+ _, err := r.pool.Exec(ctx, query, postID, userID)
+ return err
+}
+
+func (r *PostRepository) UnsavePost(ctx context.Context, postID string, userID string) error {
+ query := `DELETE FROM public.post_saves WHERE post_id = $1 AND user_id = $2`
+ _, err := r.pool.Exec(ctx, query, postID, userID)
+ return err
+}
+
+func (r *PostRepository) CreateComment(ctx context.Context, comment *models.Comment) error {
+ query := `
+ INSERT INTO public.comments (post_id, author_id, body, status, created_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ RETURNING id, created_at
+ `
+ err := r.pool.QueryRow(ctx, query, comment.PostID, comment.AuthorID, comment.Body, comment.Status).Scan(&comment.ID, &comment.CreatedAt)
+ if err != nil {
+ return err
+ }
+
+ // Increment comment count in metrics
+ _, _ = r.pool.Exec(ctx, "UPDATE public.post_metrics SET comment_count = comment_count + 1 WHERE post_id = $1", comment.PostID)
+
+ return nil
+}
+
+func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long float64, radius int) ([]models.Post, error) {
+ query := `
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at,
+ p.beacon_type, p.confidence_score, p.is_active_beacon,
+ ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long,
+ pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url
+ FROM public.posts p
+ JOIN public.profiles pr ON p.author_id = pr.id
+ WHERE p.is_beacon = true
+ AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
+ AND p.status = 'active'
+ ORDER BY p.created_at DESC
+ `
+ rows, err := r.pool.Query(ctx, query, lat, long, radius)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var beacons []models.Post
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
+ &p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ beacons = append(beacons, p)
+ }
+ return beacons, nil
+}
+
+func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit int, offset int) ([]models.Post, error) {
+ query := `
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, 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,
+ EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1) as is_liked
+ FROM public.post_saves ps
+ JOIN public.posts p ON ps.post_id = p.id
+ JOIN public.profiles pr ON p.author_id = pr.id
+ LEFT JOIN public.post_metrics m ON p.id = m.post_id
+ WHERE ps.user_id = $1 AND p.deleted_at IS NULL
+ ORDER BY ps.created_at DESC
+ LIMIT $2 OFFSET $3
+ `
+ rows, err := r.pool.Query(ctx, query, userID, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var posts []models.Post
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ posts = append(posts, p)
+ }
+ return posts, nil
+}
+
+func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit int, offset int) ([]models.Post, error) {
+ query := `
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, 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,
+ TRUE as is_liked
+ FROM public.post_likes pl
+ JOIN public.posts p ON pl.post_id = p.id
+ JOIN public.profiles pr ON p.author_id = pr.id
+ LEFT JOIN public.post_metrics m ON p.id = m.post_id
+ WHERE pl.user_id = $1 AND p.deleted_at IS NULL
+ ORDER BY pl.created_at DESC
+ LIMIT $2 OFFSET $3
+ `
+ rows, err := r.pool.Query(ctx, query, userID, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var posts []models.Post
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ posts = append(posts, p)
+ }
+ return posts, nil
+}
+
+func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]models.Post, error) {
+ // Recursive CTE to get the chain
+ query := `
+ WITH RECURSIVE object_chain AS (
+ -- Anchor member: select the root post
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at, p.chain_parent_id,
+ 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,
+ 1 as level
+ 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.id = $1 AND p.deleted_at IS NULL
+
+ UNION ALL
+
+ -- Recursive member: select children
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at, p.chain_parent_id,
+ 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,
+ oc.level + 1
+ 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
+ JOIN object_chain oc ON p.chain_parent_id = oc.id
+ WHERE p.deleted_at IS NULL
+ )
+ SELECT
+ id, author_id, category_id, body, image_url, tags, created_at,
+ author_handle, author_display_name, author_avatar_url,
+ like_count, comment_count
+ FROM object_chain
+ ORDER BY level ASC, created_at ASC;
+ `
+ rows, err := r.pool.Query(ctx, query, rootID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var posts []models.Post
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ posts = append(posts, p)
+ }
+ return posts, nil
+}
+
+func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID string, limit int) ([]models.Post, error) {
+ searchQuery := "%" + query + "%"
+ sql := `
+ SELECT
+ p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), COALESCE(p.video_url, ''), COALESCE(p.thumbnail_url, ''), p.duration_ms, COALESCE(p.tags, ARRAY[]::text[]), 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,
+ FALSE 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))
+ AND p.deleted_at IS NULL AND p.status = 'active'
+ AND (
+ p.author_id = $4
+ OR pr.is_private = FALSE
+ OR EXISTS (
+ SELECT 1 FROM public.follows f
+ WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
+ )
+ )
+ ORDER BY p.created_at DESC
+ LIMIT $3
+ `
+ rows, err := r.pool.Query(ctx, sql, searchQuery, query, limit, viewerID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var posts []models.Post
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ )
+ if err != nil {
+ return nil, err
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ posts = append(posts, p)
+ }
+ return posts, nil
+}
+
+func (r *PostRepository) SearchTags(ctx context.Context, query string, limit int) ([]models.TagResult, error) {
+ searchQuery := "%" + query + "%"
+ sql := `
+ SELECT tag, COUNT(*) as count
+ FROM (
+ SELECT unnest(tags) as tag FROM public.posts WHERE deleted_at IS NULL AND status = 'active'
+ ) t
+ WHERE tag ILIKE $1
+ GROUP BY tag
+ ORDER BY count DESC
+ LIMIT $2
+ `
+ rows, err := r.pool.Query(ctx, sql, searchQuery, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tags []models.TagResult
+ for rows.Next() {
+ var t models.TagResult
+ if err := rows.Scan(&t.Tag, &t.Count); err != nil {
+ return nil, err
+ }
+ tags = append(tags, t)
+ }
+ return tags, nil
+}
+
+// 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) {
+ // Get target post
+ targetPost, err := r.GetPostByID(ctx, postID, userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get target post: %w", err)
+ }
+
+ var parentPost *models.Post
+ var children []models.Post
+ var parentChildren []models.Post
+
+ // Get parent post if chain_parent_id exists
+ if targetPost.ChainParentID != nil {
+ parentPost, err = r.GetPostByID(ctx, targetPost.ChainParentID.String(), userID)
+ if err != nil {
+ // Parent might not exist or be inaccessible - continue without it
+ parentPost = nil
+ }
+ }
+
+ // Get direct children (1st layer replies only)
+ childrenQuery := `
+ SELECT
+ p.id,
+ p.author_id,
+ p.category_id,
+ p.body,
+ COALESCE(p.image_url, ''),
+ CASE
+ WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
+ WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
+ ELSE ''
+ END AS resolved_video_url,
+ COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
+ p.duration_ms,
+ COALESCE(p.tags, ARRAY[]::text[]),
+ 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 $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2) ELSE FALSE END as is_liked,
+ p.allow_chain, p.visibility
+ 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.chain_parent_id = $1 AND p.deleted_at IS NULL AND p.status = 'active'
+ AND (
+ p.author_id = $2
+ OR pr.is_private = FALSE
+ OR EXISTS (
+ SELECT 1 FROM public.follows f
+ WHERE f.follower_id = $2 AND f.following_id = p.author_id AND f.status = 'accepted'
+ )
+ )
+ ORDER BY p.created_at ASC
+ `
+
+ rows, err := r.pool.Query(ctx, childrenQuery, postID, userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get children posts: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &p.AllowChain, &p.Visibility,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan child post: %w", err)
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ children = append(children, p)
+ }
+
+ // If we have a parent, fetch its direct children (siblings + current)
+ if parentPost != nil {
+ siblingRows, err := r.pool.Query(ctx, childrenQuery, parentPost.ID.String(), userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get parent children: %w", err)
+ }
+ defer siblingRows.Close()
+
+ for siblingRows.Next() {
+ var p models.Post
+ err := siblingRows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &p.AllowChain, &p.Visibility,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan parent child post: %w", err)
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ parentChildren = append(parentChildren, p)
+ }
+ }
+
+ return &models.FocusContext{
+ TargetPost: targetPost,
+ ParentPost: parentPost,
+ Children: children,
+ ParentChildren: parentChildren,
+ }, nil
+}
+
+func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, userID string, emoji string) (map[string]int, []string, error) {
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to start transaction: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ var exists bool
+ err = tx.QueryRow(
+ ctx,
+ `SELECT EXISTS(
+ SELECT 1 FROM public.post_reactions
+ WHERE post_id = $1 AND user_id = $2 AND emoji = $3
+ )`,
+ postID,
+ userID,
+ emoji,
+ ).Scan(&exists)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to check reaction: %w", err)
+ }
+
+ if exists {
+ if _, err := tx.Exec(
+ ctx,
+ `DELETE FROM public.post_reactions
+ WHERE post_id = $1 AND user_id = $2 AND emoji = $3`,
+ postID,
+ userID,
+ emoji,
+ ); err != nil {
+ return nil, nil, fmt.Errorf("failed to remove reaction: %w", err)
+ }
+ } else {
+ if _, err := tx.Exec(
+ ctx,
+ `INSERT INTO public.post_reactions (post_id, user_id, emoji)
+ VALUES ($1, $2, $3)`,
+ postID,
+ userID,
+ emoji,
+ ); err != nil {
+ return nil, nil, fmt.Errorf("failed to add reaction: %w", err)
+ }
+ }
+
+ rows, err := tx.Query(
+ ctx,
+ `SELECT emoji, COUNT(*) FROM public.post_reactions
+ WHERE post_id = $1
+ GROUP BY emoji`,
+ postID,
+ )
+ if err != nil {
+ return 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, fmt.Errorf("failed to scan reaction counts: %w", err)
+ }
+ counts[reaction] = count
+ }
+ if rows.Err() != nil {
+ return nil, nil, fmt.Errorf("failed to iterate reaction counts: %w", rows.Err())
+ }
+
+ userRows, err := tx.Query(
+ ctx,
+ `SELECT emoji FROM public.post_reactions
+ WHERE post_id = $1 AND user_id = $2`,
+ postID,
+ userID,
+ )
+ if err != nil {
+ return 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, fmt.Errorf("failed to scan user reactions: %w", err)
+ }
+ myReactions = append(myReactions, reaction)
+ }
+ if userRows.Err() != nil {
+ return nil, nil, fmt.Errorf("failed to iterate user reactions: %w", userRows.Err())
+ }
+
+ if err := tx.Commit(ctx); err != nil {
+ return nil, nil, fmt.Errorf("failed to commit reaction toggle: %w", err)
+ }
+
+ return counts, myReactions, nil
+}
diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go
index f12ae57..c31ce98 100644
--- a/go-backend/cmd/api/main.go
+++ b/go-backend/cmd/api/main.go
@@ -209,6 +209,7 @@ func main() {
authorized.GET("/posts/:id", postHandler.GetPost)
authorized.GET("/posts/:id/chain", postHandler.GetPostChain)
authorized.GET("/posts/:id/thread", postHandler.GetPostChain)
+ authorized.GET("/posts/:id/focus-context", postHandler.GetPostFocusContext)
authorized.PATCH("/posts/:id", postHandler.UpdatePost)
authorized.DELETE("/posts/:id", postHandler.DeletePost)
authorized.POST("/posts/:id/pin", postHandler.PinPost)
@@ -217,6 +218,7 @@ func main() {
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
authorized.POST("/posts/:id/save", postHandler.SavePost)
authorized.DELETE("/posts/:id/save", postHandler.UnsavePost)
+ authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
authorized.GET("/feed", postHandler.GetFeed)
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
diff --git a/go-backend/internal/database/migrations/20260131000001_add_post_reactions.down.sql b/go-backend/internal/database/migrations/20260131000001_add_post_reactions.down.sql
new file mode 100644
index 0000000..8a4019d
--- /dev/null
+++ b/go-backend/internal/database/migrations/20260131000001_add_post_reactions.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS post_reactions;
diff --git a/go-backend/internal/database/migrations/20260131000001_add_post_reactions.up.sql b/go-backend/internal/database/migrations/20260131000001_add_post_reactions.up.sql
new file mode 100644
index 0000000..42cefa6
--- /dev/null
+++ b/go-backend/internal/database/migrations/20260131000001_add_post_reactions.up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS post_reactions (
+ user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ emoji TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (user_id, post_id, emoji)
+);
+
+CREATE INDEX IF NOT EXISTS idx_post_reactions_post_id ON post_reactions(post_id);
diff --git a/go-backend/internal/database/schema.sql b/go-backend/internal/database/schema.sql
index 1e52f9a..3e10faf 100644
--- a/go-backend/internal/database/schema.sql
+++ b/go-backend/internal/database/schema.sql
@@ -96,6 +96,16 @@ CREATE TABLE IF NOT EXISTS public.post_saves (
PRIMARY KEY (post_id, user_id)
);
+CREATE TABLE IF NOT EXISTS public.post_reactions (
+ post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
+ emoji TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ PRIMARY KEY (post_id, user_id, emoji)
+);
+
+CREATE INDEX IF NOT EXISTS idx_post_reactions_post_id ON public.post_reactions(post_id);
+
CREATE TABLE IF NOT EXISTS public.follows (
follower_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
following_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go
index 81b0fc4..ea9eadb 100644
--- a/go-backend/internal/handlers/post_handler.go
+++ b/go-backend/internal/handlers/post_handler.go
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
+ "strings"
"time"
"github.com/gin-gonic/gin"
@@ -56,18 +57,18 @@ func (h *PostHandler) CreateComment(c *gin.Context) {
cis := 0.8
post := &models.Post{
- AuthorID: userID,
- Body: req.Body,
- Status: "active",
- ToneLabel: &tone,
- CISScore: &cis,
- BodyFormat: "plain",
- Tags: tags,
- IsBeacon: false,
- IsActiveBeacon:false,
- AllowChain: true,
- Visibility: "public",
- ChainParentID: &parentUUID,
+ AuthorID: userID,
+ Body: req.Body,
+ Status: "active",
+ ToneLabel: &tone,
+ CISScore: &cis,
+ BodyFormat: "plain",
+ Tags: tags,
+ IsBeacon: false,
+ IsActiveBeacon: false,
+ AllowChain: true,
+ Visibility: "public",
+ ChainParentID: &parentUUID,
}
if err := h.postRepo.CreatePost(c.Request.Context(), post); err != nil {
@@ -138,19 +139,19 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
userID, _ := uuid.Parse(userIDStr.(string))
var req struct {
- CategoryID *string `json:"category_id"`
- Body string `json:"body" binding:"required,max=500"`
- ImageURL *string `json:"image_url"`
- VideoURL *string `json:"video_url"`
- Thumbnail *string `json:"thumbnail_url"`
- DurationMS *int `json:"duration_ms"`
- AllowChain *bool `json:"allow_chain"`
- ChainParentID *string `json:"chain_parent_id"`
- IsBeacon bool `json:"is_beacon"`
- BeaconType *string `json:"beacon_type"`
- BeaconLat *float64 `json:"beacon_lat"`
- BeaconLong *float64 `json:"beacon_long"`
- TTLHours *int `json:"ttl_hours"`
+ CategoryID *string `json:"category_id"`
+ Body string `json:"body" binding:"required,max=500"`
+ ImageURL *string `json:"image_url"`
+ VideoURL *string `json:"video_url"`
+ Thumbnail *string `json:"thumbnail_url"`
+ DurationMS *int `json:"duration_ms"`
+ AllowChain *bool `json:"allow_chain"`
+ ChainParentID *string `json:"chain_parent_id"`
+ IsBeacon bool `json:"is_beacon"`
+ BeaconType *string `json:"beacon_type"`
+ BeaconLat *float64 `json:"beacon_lat"`
+ BeaconLong *float64 `json:"beacon_long"`
+ TTLHours *int `json:"ttl_hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -528,6 +529,43 @@ func (h *PostHandler) GetPostChain(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"posts": posts})
}
+func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ focusContext, err := h.postRepo.GetPostFocusContext(c.Request.Context(), postID, userIDStr.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch focus context", "details": err.Error()})
+ return
+ }
+
+ h.signPostMedia(focusContext.TargetPost)
+ h.signPostMedia(focusContext.ParentPost)
+ for i := range focusContext.Children {
+ h.signPostMedia(&focusContext.Children[i])
+ }
+
+ c.JSON(http.StatusOK, focusContext)
+}
+
+func (h *PostHandler) signPostMedia(post *models.Post) {
+ if post == nil {
+ return
+ }
+ if post.ImageURL != nil {
+ signed := h.assetService.SignImageURL(*post.ImageURL)
+ post.ImageURL = &signed
+ }
+ if post.VideoURL != nil {
+ signed := h.assetService.SignVideoURL(*post.VideoURL)
+ post.VideoURL = &signed
+ }
+ if post.ThumbnailURL != nil {
+ signed := h.assetService.SignImageURL(*post.ThumbnailURL)
+ post.ThumbnailURL = &signed
+ }
+}
+
func (h *PostHandler) VouchBeacon(c *gin.Context) {
beaconID := c.Param("id")
userIDStr, _ := c.Get("user_id")
@@ -618,3 +656,33 @@ func (h *PostHandler) RemoveBeaconVote(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Beacon vote removed successfully"})
}
+
+func (h *PostHandler) ToggleReaction(c *gin.Context) {
+ postID := c.Param("id")
+ userIDStr, _ := c.Get("user_id")
+
+ var req struct {
+ Emoji string `json:"emoji" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ emoji := strings.TrimSpace(req.Emoji)
+ if emoji == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"})
+ return
+ }
+
+ counts, myReactions, err := h.postRepo.ToggleReaction(c.Request.Context(), postID, userIDStr.(string), emoji)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle reaction", "details": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "reactions": counts,
+ "my_reactions": myReactions,
+ })
+}
diff --git a/go-backend/internal/models/post.go b/go-backend/internal/models/post.go
index eff8ee9..80aee57 100644
--- a/go-backend/internal/models/post.go
+++ b/go-backend/internal/models/post.go
@@ -88,3 +88,11 @@ type TagResult struct {
Tag string `json:"tag"`
Count int `json:"count"`
}
+
+// FocusContext represents the minimal data needed for the Focus-Context view
+type FocusContext struct {
+ TargetPost *Post `json:"target_post"`
+ ParentPost *Post `json:"parent_post,omitempty"`
+ Children []Post `json:"children"`
+ ParentChildren []Post `json:"parent_children,omitempty"`
+}
diff --git a/go-backend/internal/repository/category_repository.go b/go-backend/internal/repository/category_repository.go
index 402a2bb..d873b3c 100644
--- a/go-backend/internal/repository/category_repository.go
+++ b/go-backend/internal/repository/category_repository.go
@@ -116,7 +116,7 @@ func (r *PGCategoryRepository) SetUserCategorySettings(ctx context.Context, user
}
func (r *PGCategoryRepository) GetUserCategorySettings(ctx context.Context, userID string) ([]CategorySetting, error) {
- query := `SELECT category_id, enabled FROM public.user_category_settings WHERE user_id = $1`
+ query := `SELECT category_id, enabled FROM public.user_category_settings WHERE user_id = $1::uuid`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
@@ -140,7 +140,7 @@ func (r *PGCategoryRepository) GetUserCategorySettings(ctx context.Context, user
func (r *PGCategoryRepository) GetEnabledCategoryIDs(ctx context.Context, userID string) ([]string, error) {
query := `
SELECT category_id FROM public.user_category_settings
- WHERE user_id = $1 AND enabled = true
+ WHERE user_id = $1::uuid AND enabled = true
`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
diff --git a/go-backend/internal/repository/notification_repository.go b/go-backend/internal/repository/notification_repository.go
index 77514ec..26763e4 100644
--- a/go-backend/internal/repository/notification_repository.go
+++ b/go-backend/internal/repository/notification_repository.go
@@ -39,7 +39,7 @@ func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID
query := `
SELECT token
FROM public.user_fcm_tokens
- WHERE user_id = $1
+ WHERE user_id = $1::uuid
`
rows, err := r.pool.Query(ctx, query, userID)
if err != nil {
@@ -62,7 +62,7 @@ func (r *NotificationRepository) GetFCMTokensForUser(ctx context.Context, userID
func (r *NotificationRepository) DeleteFCMToken(ctx context.Context, userID string, token string) error {
commandTag, err := r.pool.Exec(ctx, `
DELETE FROM public.user_fcm_tokens
- WHERE user_id = $1 AND token = $2
+ WHERE user_id = $1::uuid AND token = $2
`, userID, token)
if err != nil {
return err
@@ -82,7 +82,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
+ WHERE n.user_id = $1::uuid
ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3
`
diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go
index 83521db..cfec479 100644
--- a/go-backend/internal/repository/post_repository.go
+++ b/go-backend/internal/repository/post_repository.go
@@ -21,7 +21,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
// Calculate confidence score if it's a beacon
if post.IsBeacon {
var harmonyScore int
- err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1", post.AuthorID).Scan(&harmonyScore)
+ err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1::uuid", post.AuthorID).Scan(&harmonyScore)
if err == nil {
// Logic: confidence = harmony_score / 100.0 (legacy parity)
post.Confidence = float64(harmonyScore) / 100.0
@@ -91,7 +91,7 @@ func (r *PostRepository) GetRandomSponsoredPost(ctx context.Context, userID stri
AND (
p.category_id IS NULL OR EXISTS (
SELECT 1 FROM public.user_category_settings ucs
- WHERE ucs.user_id = NULLIF($1::text, '')::uuid AND ucs.category_id = p.category_id AND ucs.enabled = true
+ WHERE ucs.user_id = CASE WHEN $1::text != '' THEN $1::text::uuid ELSE NULL END AND ucs.category_id = p.category_id AND ucs.enabled = true
)
)
ORDER BY RANDOM()
@@ -134,7 +134,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
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::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = NULLIF($4::text, '')::uuid) ELSE FALSE END as is_liked,
+ CASE WHEN ($4::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $4::text::uuid) ELSE FALSE END as is_liked,
p.allow_chain, p.visibility
FROM public.posts p
JOIN public.profiles pr ON p.author_id = pr.id
@@ -142,11 +142,11 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
LEFT JOIN public.categories c ON p.category_id = c.id
WHERE p.deleted_at IS NULL AND p.status = 'active'
AND (
- p.author_id = NULLIF($4::text, '')::uuid -- My own posts
+ p.author_id = CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END -- My own posts
OR pr.is_private = FALSE -- Public profiles
OR EXISTS (
SELECT 1 FROM public.follows f
- WHERE f.follower_id = NULLIF($4::text, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
+ WHERE f.follower_id = CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
)
)
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
@@ -225,11 +225,11 @@ func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string,
LEFT JOIN public.post_metrics m ON p.id = m.post_id
WHERE p.author_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
AND (
- p.author_id = NULLIF($4, '')::uuid -- Viewer is author
+ p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END -- Viewer is author
OR pr.is_private = FALSE -- Public profile
OR EXISTS (
SELECT 1 FROM public.follows f
- WHERE f.follower_id = NULLIF($4, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
+ WHERE f.follower_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
)
)
ORDER BY p.created_at DESC
@@ -280,27 +280,31 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
p.duration_ms,
COALESCE(p.tags, ARRAY[]::text[]),
p.created_at,
+ p.chain_parent_id,
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 $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::uuid) ELSE FALSE END as is_liked
+ CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::uuid) ELSE FALSE END as is_liked,
+ p.allow_chain, p.visibility
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.id = $1::uuid AND p.deleted_at IS NULL
AND (
- p.author_id = NULLIF($2, '')::uuid
+ p.author_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END
OR pr.is_private = FALSE
OR EXISTS (
SELECT 1 FROM public.follows f
- WHERE f.follower_id = NULLIF($2, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
+ WHERE f.follower_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
)
)
`
var p models.Post
err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.ChainParentID,
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
&p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &p.AllowChain, &p.Visibility,
)
if err != nil {
return nil, err
@@ -368,25 +372,63 @@ func (r *PostRepository) UpdateVisibility(ctx context.Context, postID string, au
}
func (r *PostRepository) LikePost(ctx context.Context, postID string, userID string) error {
- query := `INSERT INTO public.post_likes (post_id, user_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
+ query := `
+ WITH inserted AS (
+ INSERT INTO public.post_likes (post_id, user_id)
+ VALUES ($1::uuid, $2::uuid)
+ ON CONFLICT DO NOTHING
+ RETURNING 1
+ )
+ UPDATE public.post_metrics
+ SET like_count = like_count + (SELECT COUNT(*) FROM inserted)
+ WHERE post_id = $1::uuid
+ `
_, err := r.pool.Exec(ctx, query, postID, userID)
return err
}
func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID string) error {
- query := `DELETE FROM public.post_likes WHERE post_id = $1::uuid AND user_id = $2::uuid`
+ query := `
+ WITH deleted AS (
+ DELETE FROM public.post_likes
+ WHERE post_id = $1::uuid AND user_id = $2::uuid
+ RETURNING 1
+ )
+ UPDATE public.post_metrics
+ SET like_count = GREATEST(like_count - (SELECT COUNT(*) FROM deleted), 0)
+ WHERE post_id = $1::uuid
+ `
_, err := r.pool.Exec(ctx, query, postID, userID)
return err
}
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
- query := `INSERT INTO public.post_saves (post_id, user_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
+ query := `
+ WITH inserted AS (
+ INSERT INTO public.post_saves (post_id, user_id)
+ VALUES ($1::uuid, $2::uuid)
+ ON CONFLICT DO NOTHING
+ RETURNING 1
+ )
+ UPDATE public.post_metrics
+ SET save_count = save_count + (SELECT COUNT(*) FROM inserted)
+ WHERE post_id = $1::uuid
+ `
_, err := r.pool.Exec(ctx, query, postID, userID)
return err
}
func (r *PostRepository) UnsavePost(ctx context.Context, postID string, userID string) error {
- query := `DELETE FROM public.post_saves WHERE post_id = $1::uuid AND user_id = $2::uuid`
+ query := `
+ WITH deleted AS (
+ DELETE FROM public.post_saves
+ WHERE post_id = $1::uuid AND user_id = $2::uuid
+ RETURNING 1
+ )
+ UPDATE public.post_metrics
+ SET save_count = GREATEST(save_count - (SELECT COUNT(*) FROM deleted), 0)
+ WHERE post_id = $1::uuid
+ `
_, err := r.pool.Exec(ctx, query, postID, userID)
return err
}
@@ -462,12 +504,12 @@ func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit
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,
- EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1) as is_liked
+ EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1::uuid) as is_liked
FROM public.post_saves ps
JOIN public.posts p ON ps.post_id = p.id
JOIN public.profiles pr ON p.author_id = pr.id
LEFT JOIN public.post_metrics m ON p.id = m.post_id
- WHERE ps.user_id = $1 AND p.deleted_at IS NULL
+ WHERE ps.user_id = $1::uuid AND p.deleted_at IS NULL
ORDER BY ps.created_at DESC
LIMIT $2 OFFSET $3
`
@@ -516,7 +558,7 @@ func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit
JOIN public.posts p ON pl.post_id = p.id
JOIN public.profiles pr ON p.author_id = pr.id
LEFT JOIN public.post_metrics m ON p.id = m.post_id
- WHERE pl.user_id = $1 AND p.deleted_at IS NULL
+ WHERE pl.user_id = $1::uuid AND p.deleted_at IS NULL
ORDER BY pl.created_at DESC
LIMIT $2 OFFSET $3
`
@@ -567,7 +609,7 @@ func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]mod
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.id = $1 AND p.deleted_at IS NULL
+ WHERE p.id = $1::uuid AND p.deleted_at IS NULL
UNION ALL
@@ -676,11 +718,11 @@ func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID
WHERE (p.body ILIKE $1 OR $2 = ANY(p.tags))
AND p.deleted_at IS NULL AND p.status = 'active'
AND (
- p.author_id = NULLIF($4, '')::uuid
+ p.author_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END
OR pr.is_private = FALSE
OR EXISTS (
SELECT 1 FROM public.follows f
- WHERE f.follower_id = NULLIF($4, '')::uuid AND f.following_id = p.author_id AND f.status = 'accepted'
+ WHERE f.follower_id = CASE WHEN $4 != '' THEN $4::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
)
)
ORDER BY p.created_at DESC
@@ -857,3 +899,223 @@ func (r *PostRepository) RemoveBeaconVote(ctx context.Context, beaconID string,
return nil
}
+
+// 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) {
+ // Get target post
+ targetPost, err := r.GetPostByID(ctx, postID, userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get target post: %w", err)
+ }
+
+ var parentPost *models.Post
+ var children []models.Post
+ var parentChildren []models.Post
+
+ // Get parent post if chain_parent_id exists
+ if targetPost.ChainParentID != nil {
+ parentPost, err = r.GetPostByID(ctx, targetPost.ChainParentID.String(), userID)
+ if err != nil {
+ // Parent might not exist or be inaccessible - continue without it
+ parentPost = nil
+ }
+ }
+
+ // Get direct children (1st layer replies only)
+ childrenQuery := `
+ SELECT
+ p.id, p.author_id, p.category_id, p.body,
+ COALESCE(p.image_url, ''),
+ CASE
+ WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
+ WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
+ ELSE ''
+ END AS resolved_video_url,
+ COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
+ p.duration_ms,
+ COALESCE(p.tags, ARRAY[]::text[]),
+ 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 $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2::uuid) ELSE FALSE END as is_liked,
+ p.allow_chain, p.visibility
+ 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.chain_parent_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
+ AND (
+ p.author_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END
+ OR pr.is_private = FALSE
+ OR EXISTS (
+ SELECT 1 FROM public.follows f
+ WHERE f.follower_id = CASE WHEN $2 != '' THEN $2::uuid ELSE NULL END AND f.following_id = p.author_id AND f.status = 'accepted'
+ )
+ )
+ ORDER BY p.created_at ASC
+ `
+
+ rows, err := r.pool.Query(ctx, childrenQuery, postID, userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get children posts: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var p models.Post
+ err := rows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &p.AllowChain, &p.Visibility,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan child post: %w", err)
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ children = append(children, p)
+ }
+
+ // If we have a parent, fetch its direct children (siblings + current)
+ if parentPost != nil {
+ siblingRows, err := r.pool.Query(ctx, childrenQuery, parentPost.ID.String(), userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get parent children: %w", err)
+ }
+ defer siblingRows.Close()
+
+ for siblingRows.Next() {
+ var p models.Post
+ err := siblingRows.Scan(
+ &p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
+ &p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
+ &p.LikeCount, &p.CommentCount, &p.IsLiked,
+ &p.AllowChain, &p.Visibility,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan parent child post: %w", err)
+ }
+ p.Author = &models.AuthorProfile{
+ ID: p.AuthorID,
+ Handle: p.AuthorHandle,
+ DisplayName: p.AuthorDisplayName,
+ AvatarURL: p.AuthorAvatarURL,
+ }
+ parentChildren = append(parentChildren, p)
+ }
+ }
+
+ return &models.FocusContext{
+ TargetPost: targetPost,
+ ParentPost: parentPost,
+ Children: children,
+ ParentChildren: parentChildren,
+ }, nil
+}
+
+func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, userID string, emoji string) (map[string]int, []string, error) {
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to start transaction: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ var exists bool
+ 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
+ )`,
+ postID,
+ userID,
+ emoji,
+ ).Scan(&exists)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to check reaction: %w", err)
+ }
+
+ if exists {
+ if _, err := tx.Exec(
+ ctx,
+ `DELETE FROM public.post_reactions
+ WHERE post_id = $1::uuid AND user_id = $2::uuid AND emoji = $3`,
+ postID,
+ userID,
+ emoji,
+ ); err != nil {
+ return nil, nil, fmt.Errorf("failed to remove 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)
+ }
+ }
+
+ rows, err := tx.Query(
+ ctx,
+ `SELECT emoji, COUNT(*) FROM public.post_reactions
+ WHERE post_id = $1::uuid
+ GROUP BY emoji`,
+ postID,
+ )
+ if err != nil {
+ return 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, fmt.Errorf("failed to scan reaction counts: %w", err)
+ }
+ counts[reaction] = count
+ }
+ if rows.Err() != nil {
+ return nil, nil, fmt.Errorf("failed to iterate reaction counts: %w", rows.Err())
+ }
+
+ userRows, err := tx.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, 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, fmt.Errorf("failed to scan user reactions: %w", err)
+ }
+ myReactions = append(myReactions, reaction)
+ }
+ if userRows.Err() != nil {
+ return nil, nil, fmt.Errorf("failed to iterate user reactions: %w", userRows.Err())
+ }
+
+ if err := tx.Commit(ctx); err != nil {
+ return nil, nil, fmt.Errorf("failed to commit reaction toggle: %w", err)
+ }
+
+ return counts, myReactions, nil
+}
diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go
index 7d75b85..e9c633f 100644
--- a/go-backend/internal/repository/user_repository.go
+++ b/go-backend/internal/repository/user_repository.go
@@ -94,7 +94,7 @@ func (r *UserRepository) UpdateProfile(ctx context.Context, profile *models.Prof
is_private = COALESCE($12, is_private),
is_official = COALESCE($13, is_official),
updated_at = NOW()
- WHERE id = $14
+ WHERE id = $14::uuid
`
_, err := r.pool.Exec(ctx, query,
profile.Handle, profile.DisplayName, profile.Bio, profile.AvatarURL,
@@ -889,7 +889,7 @@ func (r *UserRepository) DeletePasswordResetToken(ctx context.Context, tokenHash
}
func (r *UserRepository) UpdateUserPassword(ctx context.Context, userID string, passwordHash string) error {
- query := `UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`
+ query := `UPDATE public.users SET password_hash = $1, updated_at = NOW() WHERE id = $2::uuid`
_, err := r.pool.Exec(ctx, query, passwordHash, userID)
return err
}
diff --git a/go-backend/sojorn-api b/go-backend/sojorn-api
new file mode 100644
index 0000000..72d1ccc
Binary files /dev/null and b/go-backend/sojorn-api differ
diff --git a/logo.ai b/logo.ai
index 23ebfbc..2159604 100644
--- a/logo.ai
+++ b/logo.ai
@@ -23,9 +23,9 @@
Adobe Illustrator 28.2 (Windows)
- 2026-01-29T18:59:09-05:00
- 2026-01-29T18:59:12-06:00
- 2026-01-29T18:59:12-06:00
+ 2026-01-31T01:04:41-05:00
+ 2026-01-31T01:04:42-06:00
+ 2026-01-31T01:04:42-06:00
@@ -39,7 +39,7 @@
proof:pdf
uuid:65E6390686CF11DBA6E2D887CEACB407
xmp.did:f1ac5a3d-24a7-3a42-97be-4ff736186785
- uuid:bbebb270-5505-4535-9856-6580bdf9beba
+ uuid:969d18cd-cea5-41c4-a805-4ed6b5735bab
uuid:b1f160cb-0be9-4520-b3b1-5dbb0f5ac58e
xmp.did:ec112e15-240d-9846-aa81-7ebe37ae7969
@@ -606,19 +606,19 @@
-endstream
endobj
3 0 obj
<>
endobj
5 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 174.79 43.8333]/Type/Page/PieceInfo<>>>
endobj
25 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 747.0 747.0]/Type/Page/PieceInfo<>>>
endobj
26 0 obj
<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 24.0 24.0]/Type/Page/PieceInfo<>>>
endobj
27 0 obj
<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 800.368 694.643]/Type/Page/PieceInfo<>>>
endobj
28 0 obj
<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 23.4969 14.7325]/Type/Page/PieceInfo<>>>
endobj
29 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 1011.82 382.471]/Type/Page/PieceInfo<>>>
endobj
35 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 750.268 437.492]/Type/Page/PieceInfo<>>>
endobj
124 0 obj
<>stream
+endstream
endobj
3 0 obj
<>
endobj
5 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 174.79 43.8333]/Type/Page/PieceInfo<>>>
endobj
25 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 747.0 747.0]/Type/Page/PieceInfo<>>>
endobj
26 0 obj
<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 24.0 24.0]/Type/Page/PieceInfo<>>>
endobj
27 0 obj
<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 800.368 694.643]/Type/Page/PieceInfo<>>>
endobj
28 0 obj
<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 23.4969 14.7325]/Type/Page/PieceInfo<>>>
endobj
29 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 1011.82 382.471]/Type/Page/PieceInfo<>>>
endobj
35 0 obj
<>/ExtGState<>/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 750.268 437.492]/Type/Page/PieceInfo<>>>
endobj
124 0 obj
<>stream
H‰Œ—Ën]EEç÷+úN»«ú=˜€ æ
‘ø{Ö®sm_] EŠÝuº«ë±÷®öÃwéáÛÇ’Þ½L—ßòßÃi²ÓôéRR«3·íiö’}¬t¼þúåòSúÈž‡¯(é·Ïìýñ›‡¯žKzÿçåûÿÚíÆn7v¿±û½žv}iÕr=ym¹‘žß,¶‰ª§Öj^…uy;ë¾sñùº~º|iùãÒfËÞÖÛ™5r_ëÍçËúz+>¾°üñfi;ϲҫÏá¹ðóõÖëú&Ž«/±¿œyÉíÅçÙ?½Õ½ª^Gó\[¯ÖRË£Z7ºùííë®J¬ÌÎõµäÙ,ìOÏ—w?§‡~/éóïéÃÏé¬úÿì}»éY»Óû~Ç>nìýÆ>ïì_wìûæî‚Ñî}ð{ê½÷’¶kÖ˜Ÿ>'Õ°¾þ¿ÆLéóÓÇ×6~J–
ÿàˆæ-YgtHŸž/N[gÚ¹ö–zv>Ù$;ͼ ÝW.xº,vÔä;×vß=Õšë°ôÏÅ N@“k5ÏŪƒ’šþ¾4Ë|pój3Mz\5ó4ö–š¥ÂL®1œX÷4ZUÉÆÌ¶›–ƒÃ¶AËIˆ¯Æv7Ág§Îe&Î\pÛˆ¯Žy—šZÉÝb» [I±äædSD<D<œPݱc𞇘m¹7¥ðy•O=ƒ¯B9Ñ®úÓaef¯+-Ý4X×AÒƒ€ó¢k¬7‘èÈá¤:d"–²äÍòRF4³pxsÖµÃÊUjŽM~ŠK¦âü2WÞÜK=ºÅ*Â`¹§ë@oyVí§§|jnÚh4·nuzXŽ\‹n ‘Eï±Ô¼iô4.T…\Y›J¡Øzª”¾Š1k Ï&5¢cGhÏ vÚ‰ÇØ ö…HP
ɪoFe oÏÜÎFäfKšæ4 J¡VÔÆõ%º5I럋´Õ’Üñ®Zvb¡‘ôc ì· :’²äS½(GS+T.#œ±£-°YñB˾ˆCºfˆ-8ÃOòlÐåVééò«ûÊ8*cqÄ€îÚ4ç•qY¸ÀÜI†¦iÙ£sú!Z5 CkAŒ>¯Èe;V¥‘°?á$Õ›± ˜ÐS–Ä×AáÐU7Á®ÑÔ±5É/ÓªXáÁ†U¨Ý˜›!/õìÜhf°R¥ â^RÆU¶…B½Jiø@ù@àî~¶FrÊ‹(G‰·¶3׺æ8òÖB ªkfÍHHA4G6˜_WýÃÒ Jâ‡bÜàKÍ¥>qF$€d‰<#0ATÈU,„" ƒñ%ªHñF¸uׄ¢Ét˜;áŸKµ4`â¸=æYDQ|4qŽLfëiæ•úËê¾²‡4‘×¢‰´å0•ŠY&±sQý~Êq²K3Ð¡éŸøËf’….
‰+ýS=Té¥Ó Ýf?Ej¹`AÖUr@mÕ*ƒ`¦LpÂ…b¨0ó`2l+‚EHV¼õyõ-ÎÌБ“üIÎjß‹¥”´œ‘AOœz€c û¸75ÝTuAâìra•°tdxMi -^ž¹ƒÆG`dö(¹RìB°E[«š‡šµÚ¢GRRÐBÍye¥´?l!KÕ#Ø&ùG¾;ºüŸÉnèG9À`_j23 œc]ijf1ø… Wãm+ê/µ5Â×\§ðW8b³zjriÒT0fcœ"<¡ÒÒ¬D¡Ú¼Û¶…Ò*TÉ|l@É8°¥k+&ˆŠÚCžTÔUN™£ªr¸4uQ Gå•ÄT“˜¶UzŒ¤Yë•sjßA—KDñ%ŸU¤@*¸æ‘ôø<±;±4Sdxda)HÆ‘OψÍr¿\Æ]¥27ç:ž‰œÝ½)¸ÔK@,G‰`áùäæy‹kMnpª9½6BÓ¬‡L 3zyèí;V{Ó½…£T¹ë-Ô˜‚=>õ†§ódˆ<¹TföñüçE3…Q†Ö‚*qo3F’
½“Å£*mˆ¤Þ ØšÎP†3·cäö'ÿïßRõÆôýå_ ÿÿ ÿÿ ýc©<
-endstream
endobj
125 0 obj
<>
endobj
9 0 obj
<>
endobj
11 0 obj
<>
endobj
12 0 obj
<>stream
+endstream
endobj
125 0 obj
<>
endobj
9 0 obj
<>
endobj
11 0 obj
<>
endobj
12 0 obj
<>stream
%!PS-Adobe-3.0
%%Creator: Adobe Illustrator(R) 24.0
%%AI8_CreatorVersion: 28.2.0
%%For: (Patrick) ()
%%Title: (logo.ai)
-%%CreationDate: 1/29/2026 6:59 PM
+%%CreationDate: 1/31/2026 1:04 AM
%%Canvassize: 16383
%%BoundingBox: -687 243 2183 2139
%%HiResBoundingBox: -686.36947404472 243.805732484076 2182.04803022612 2138.32476648632
@@ -640,9 +640,9 @@ endstream
endobj
125 0 obj
<>
endobj
%AI5_TargetResolution: 800
%AI5_NumLayers: 4
%AI17_Begin_Content_if_version_gt:24 4
-%AI10_OpenToVie: 399.847133757961 538.668789808913 4.36111111111111 0 8081.54140127388 7374.15286624204 1555 940 18 1 0 46 87 0 0 0 0 1 0 1 1 0 1
+%AI10_OpenToVie: 399 539 4.36111111111111 0 8081.54140127388 7374.15286624204 1555 940 18 1 0 46 87 0 0 0 0 1 0 1 1 0 1
%AI17_Alternate_Content
-%AI9_OpenToView: 399.847133757961 538.668789808913 4.36111111111111 1555 940 18 1 0 46 87 0 0 0 0 1 0 1 1 0 1
+%AI9_OpenToView: 399 539 4.36111111111111 1555 940 18 1 0 46 87 0 0 0 0 1 0 1 1 0 1
%AI17_End_Versioned_Content
%AI5_OpenViewLayers: 7776
%AI17_Begin_Content_if_version_gt:24 4
@@ -655,343 +655,348 @@ endstream
endobj
125 0 obj
<>
endobj
%%EndComments
endstream
endobj
13 0 obj
<>stream
-%AI24_ZStandard_Data(µ/ý X¼Tþ¯Ee.°ÔÌ&m8Ÿ^J)¼A(æ•H‰&°ÿÿ¿tN‘ÝÝ›Ë`Zñ’éOéË•ý”àÖa(>ýiÓ,-²µ"âPAãm}iNôϦÆÄ½mö¥·¯…Zϼ¥³®(ë2«öj5‘¤ÜH«…‚Á¸£á%œöT ‰ÕUõºÐ†ÒÚ·±t;^ÿ/Þ8%*q‰LÜ‘°Â¨ E™þ‰'/ÛE@ÿ¯žY9J4ûJÕNdÛP±Ã$M;Ä£ç&TŠE#Éæs¡g´…$
-4ÛA$Q†NxáP(Ö1
-ËnéXé‡ôMªDC-‰ž¾ýyµ4[ýh©4MÑ` ‘HF.vaUVÕ—
UÉB«sΊm³dÁœ=±˜KJ•Jµ—Ì–¨g¾K£>©Nf’fIõ·ÆR‡bÁH$ù‚$vx†Ã $IÝEF"¡ …ó
-…báTP2ì
Eƒad8$É«†H¢W4j¨]‘ÄquÂ$¶r" 9EŽ=óÂí /’ ÉW‘$ñIO$q ÉH’ÐÒʯ&ž!‰ŠÄsƒ‘H I#Tàâ6$±0Y£•¨/¬8ÛB• ˆˆ\D‚BGȉW§ÛÚËM»iµ†Š%gHâHØ!‰¡‹¡ÓŒt+žéCå‰_¬ËüÑí]¢µî¾fžK:
²}É@k,ºÈÅ-jA‹†‚¡P(®P*La…#ÁÈÄ5¾ø¯ÇÆ.I¾JÛÐ…^Õ
-cM‘†ÊÐJlbËH
†ÃIêŒ$Å@’#‰ã
4$Ñ͵I°a†Â
-R’¬’0˜($I`2*‘‹[ï…‹YÏýb¥Ñ—p«$d$±C$9£á@Ëe ‰ËÎóF$1VêH‚ú¼Âô
-$ahô„$$±CLE’}‚q$a4È…$‡©û„¢á°! *R‘O0®ÃÔ„¢JwŸ9$Ù,Å"‰-Œj0O<‹ñ“cËP˜‰55F*2±hll6I~{Y‹#’dÃd$)$q0H’ø"‰ƒ$ŠÄ
ÆBqEDÅ)’$Ò¹‘ÈûBâ!ÂBœ}¨M:˰r móê¿¥uút7
Ë¡2øÂÍÌ×¢Z
|^%.ÞN7“¹7V”…ÿ¿¡bTø-ßÞ5wjíæ?•™Yžžòîj*y7šÚ)òŒV•Èô†ª•ù’,£A¶ÃÜ'„ʾp¦"Ég4l¨H$¹ÂAH¢{’Ës$ Å‚Ñ0’ ’à&I,T§@M ÉBÁP0Œ¡i[$´F"$j5ž™FÃ$` ‡$ÆÑd±Å8’0N
d c‘$¯òÐ$Åí›X
…#±ð.
-ÊP’¾’Ò±˜xŠK0´±X4Hb \4ð¬Ì`cñ–pÂqljt,㫦0\¡‚$dàÍ&¯dq)ÏB{ï–¶4*3šS¡¦ZÍIH2ƒ† €BÁwlËÍv´N„zCFZf¹öëk«”i·=ã^ýf»x{´5[L¼Ö×ê¼ÕÝ}–©ªHi¨k÷j÷®S:$›ÚMty¨¤Ú÷ÂTïÐ!Þh¦Ww•KJüSš¦'s¼8uÝ1-JzC[²P©âb$É@ƒ$Z
-$I4é…‘ˆ$KhNÐ"±×çÝ|Z¬â»H’‹{kk[k†.¨§§FŒ\I4óÒèÀ†â¸ˆWh$Å)IîìêÊa
…5Ž$HâØ…"ñU$9c±P ɯç@’’0d¡@’ÄI4ÑÊ$q4H¢HâE’-F’óe]¥Ãffiiº¶v»! ƒÎa>ß^"Áh
I L8èÃÍ„"$ñijªê*T¡‡oQ±ŒmÐ$«xâ‹7î0M¬"±0UìBÁ¨u$q0JoG!’’ !)iê2HÂh”Í\>4æN,Iv‹Z”!‰‘¤ì0‘DŸX É‚’hIH’{Å‚Á¸Cíh4ÌÁh0Œ•°•ɲ„¡ãÖp£»DæzQZi†‚‘°§äp4D6ÒE
£±`(Õ0r”ãê¸ÃVWæS¯¯ð²#‰¦òn}QŒj”£ÈlefghijH²vK˜Âư†9ŒÎW‡$‰$‰„"±H0„#$¤°¢¦8*R¡ŠU\‘P(CÑP8´ð¢¶¸îâ‹cÑX8dŒc #Ê@’eœ‘`(ƒnTò¡m¼‘`ÃŽrX:4áP4hA.;Ì….DºO(Ì@’²ÃÔ}B$T—H²@’s\6ŒcÁh0HB.C$ÁÔ½¢¡@’E£Ñ $)GÙaê:î@’'‹†Iàh8Ž…Cápܱuä9Êa‡£Ñh £±h(ר†Fƒ±`(g,CÉ@2Îb8ŒÅb¡X$‹‹[’xAGÂHÒ8ÒBz]ÕR+©#¹HE&.IÈH$šãÇHÜžÞ®aS˜Ânkjigæ@E%Òaƽ·ØsY™£Á¨D%$Q‰Ú¯–¢•$EÂëûšéÄs!I¹ì ‘b(H$ J‚”ø•––®„GHr„Ff2)¯ÕB’¬¬,D4*I/¬X(œHøþ*’¤Óh<+Û‰ƒÄ ƒ¤ …"qMBB<+’À¤ÅI&xàÐâðp áj
†I$(~Xj¶[¥|•ò½ÐjI¯LQ-“}×PqÙŽN&_Y!‰E ÄE
-‘aÉâREsÓu£=áie
±tM:Trm黆eH;¡h A9FÃÁ WIªQv˜ºO(H²`4
rÙ¡ã/Ooï—8Å-Žqs¡±Ðð1QqIŽ TàØ@R:+-/1357Ÿ‰Tä"ÙHG’ZKMOQUW¯ UèBÚP‡Òz‘¤Öö7Ww÷›XÅ.–±u,ݯÛïø¼~ÿ'®øâŒ7î8%œ¨„%.‰LhbO’”I|¦s’IÏ&ŽŽ7ÎøâŠÿû<þn¯ÓËXÅ&vW7÷¶ÖëP†"ÉBºšzjjud#™ÈMMLK'e¨À£"¢¡‘°q‹KÜO/ï®Îç0†-,akHb $9 ’0ŠÄâ‹/v$«‰·°…ÕÂ/hA‡¢¡h(Š…b¡P(Š„âŠ+V$ªHE*PqŠS˜Â¥°Â
-R8ŽD#ÁH,Š„"‘H$OlbšÐÄÄ%Ε¨„Hb ŠR…
-’L°X@¡ ÂáÑà`Šb$! 0Â#BB< 04D@,@ ‚„ˆLðÀ`ኇ DxX<"&XP 8pAÄ,ñpX€ ‡†@,8,@x€ 8< (Ê6ÐÀ€"B`ÄCDƒƒF) hˆ`x` â.À`âA‹ˆ„‡ D@$ <