sojorn/go-backend/cmd/api/main.go
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

332 lines
12 KiB
Go

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()
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
// 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)
backupHandler := handlers.NewBackupHandler(repository.NewBackupRepository(dbPool))
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("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
r.HEAD("/health", func(c *gin.Context) {
c.Status(200)
})
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(cfg.JWTSecret))
{
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/thread", postHandler.GetPostChain)
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/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.DELETE("/conversations/:id", chatHandler.DeleteConversation)
authorized.DELETE("/messages/:id", chatHandler.DeleteMessage)
authorized.GET("/mutual-follows", chatHandler.GetMutualFollows)
// Key routes
authorized.POST("/keys", keyHandler.PublishKeys)
authorized.GET("/keys/:id", keyHandler.GetKeyBundle)
authorized.DELETE("/keys/otk/:keyId", keyHandler.DeleteUsedOTK)
// Backup & Recovery routes
backupGroup := authorized.Group("/backup")
{
backupGroup.POST("/sync/generate-code", backupHandler.GenerateSyncCode)
backupGroup.POST("/sync/verify-code", backupHandler.VerifySyncCode)
backupGroup.POST("/upload", backupHandler.UploadBackup)
backupGroup.GET("/download", backupHandler.DownloadBackup)
backupGroup.GET("/download/:backupId", backupHandler.DownloadBackup)
backupGroup.GET("/list", backupHandler.ListBackups)
backupGroup.DELETE("/:backupId", backupHandler.DeleteBackup)
backupGroup.GET("/preferences", backupHandler.GetBackupPreferences)
backupGroup.PUT("/preferences", backupHandler.UpdateBackupPreferences)
}
recoveryGroup := authorized.Group("/recovery")
{
recoveryGroup.POST("/social/setup", backupHandler.SetupSocialRecovery)
recoveryGroup.POST("/initiate", backupHandler.InitiateRecovery)
recoveryGroup.POST("/submit-shard", backupHandler.SubmitShard)
recoveryGroup.POST("/complete/:sessionId", backupHandler.CompleteRecovery)
}
// Device management routes
authorized.GET("/devices", backupHandler.GetUserDevices)
// 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: ":" + 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")
}