package server import ( "context" "net/http" "path/filepath" "github.com/go-chi/chi/v5" chiMiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/prabhupadasays/spsays-bff/internal/config" "github.com/prabhupadasays/spsays-bff/internal/handler" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/internal/worker" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/mailer" "github.com/prabhupadasays/spsays-bff/pkg/oauth" "github.com/prabhupadasays/spsays-bff/pkg/realtime" "github.com/prabhupadasays/spsays-bff/pkg/shorturl" "go.uber.org/zap" ) // Server holds all dependencies for the HTTP server. type Server struct { cfg *config.Config logger *zap.Logger router chi.Router mongo *repository.MongoDB pg *repository.Postgres redis *repository.Redis // Worker lifecycle workerCancel context.CancelFunc workerCtx context.Context // derived in initShortener; cancelled on Close // Handlers bookH *handler.BookHandler letterH *handler.LetterHandler essayH *handler.EssayHandler glossaryH *handler.GlossaryHandler discipleH *handler.DiscipleHandler journalH *handler.JournalHandler magazineH *handler.MagazineHandler otherDocH *handler.OtherDocumentHandler transcriptionH *handler.TranscriptionHandler searchH *handler.SearchHandler shortURLH *handler.ShortURLHandler authH *handler.AuthHandler oauthH *handler.OAuthHandler userH *handler.UserHandler postH *handler.PostHandler bookmarkH *handler.BookmarkHandler collectionH *handler.CollectionHandler profileH *handler.ProfileHandler relationH *handler.RelationshipHandler notificationH *handler.NotificationHandler sadhnaH *handler.SadhnaHandler pushH *handler.PushHandler reminderH *handler.ReminderHandler feedbackH *handler.FeedbackHandler statsH *handler.StatsHandler // Auth state (nil when Postgres is unavailable — /auth and /users return 503) authIssuer *auth.Issuer userRepo *repository.UserRepo } // New creates a new Server with all routes registered. func New(cfg *config.Config, logger *zap.Logger) *Server { mongoDB, err := repository.NewMongoDB(cfg.MongoURI, cfg.MongoDB, logger) if err != nil { logger.Fatal("failed to connect to MongoDB", zap.Error(err)) } // Initialize Typesense client tsClient := repository.NewTypesenseClient(cfg.TypesenseURL, cfg.TypesenseAPIKey) // Initialize repositories bookRepo := repository.NewBookRepo(mongoDB) letterRepo := repository.NewLetterRepo(mongoDB) essayRepo := repository.NewEssayRepo(mongoDB) glossaryRepo := repository.NewGlossaryRepo(mongoDB) discipleRepo := repository.NewDiscipleRepo(mongoDB) journalRepo := repository.NewJournalRepo(mongoDB) magazineRepo := repository.NewMagazineRepo(mongoDB) otherDocRepo := repository.NewOtherDocumentRepo(mongoDB) transcriptionRepo := repository.NewTranscriptionRepo(mongoDB) s := &Server{ cfg: cfg, logger: logger, mongo: mongoDB, // Initialize handlers bookH: handler.NewBookHandler(bookRepo), letterH: handler.NewLetterHandler(letterRepo), essayH: handler.NewEssayHandler(essayRepo), glossaryH: handler.NewGlossaryHandler(glossaryRepo), discipleH: handler.NewDiscipleHandler(discipleRepo), journalH: handler.NewJournalHandler(journalRepo), magazineH: handler.NewMagazineHandler(magazineRepo), otherDocH: handler.NewOtherDocumentHandler(otherDocRepo), transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo), searchH: handler.NewSearchHandler(tsClient), } // URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers. // Connection failures degrade gracefully — the BFF stays up; only /share returns 503. s.initShortener(cfg, logger) // Auth: depends on Postgres being up. initShortener already opened the pool, // so we reuse s.pg here. On a fresh DB the users + auth_tokens tables get // created by the migration runner that initShortener already invoked. s.initAuth(cfg, logger) s.setupRouter() return s } // initAuth wires the user repo, JWT issuer, mailer, R2 client, and the auth // + user handlers. If Postgres is unavailable, leaves the handlers nil and // the routes 503-out gracefully. func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { if s.pg == nil { logger.Warn("auth: Postgres unavailable — /auth and /users will return 503") return } userRepo := repository.NewUserRepo(s.pg) tokenRepo := repository.NewAuthTokenRepo(s.pg) oauthRepo := repository.NewOAuthAccountRepo(s.pg) r2, err := repository.NewR2(repository.R2Config{ AccountID: cfg.R2AccountID, AccessKeyID: cfg.R2AccessKeyID, SecretAccessKey: cfg.R2SecretAccessKey, Bucket: cfg.R2Bucket, PublicURL: cfg.R2PublicURL, }) if err != nil { logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err)) } if r2 == nil { logger.Info("auth: R2 not configured — avatar presign endpoint will return 503") } issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration) mail := mailer.New(mailer.Config{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, From: cfg.SMTPFrom, }, logger) authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL) userSvc := service.NewUserService(userRepo, r2) // Social repositories + services share the same Postgres pool and R2 client. postRepo := repository.NewPostRepo(s.pg) bookmarkRepo := repository.NewBookmarkRepo(s.pg) collectionRepo := repository.NewCollectionRepo(s.pg) postSvc := service.NewPostService(postRepo, r2) bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo) collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2) cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure} s.authIssuer = issuer s.userRepo = userRepo s.authH = handler.NewAuthHandler(authSvc, cookieOpts) s.userH = handler.NewUserHandler(userSvc) // OAuth — wired only when at least one provider is configured. The // registry skips providers with missing creds, so partial configs (e.g. // Google only, no Facebook) work out of the box. providers := oauth.NewRegistry(oauth.Credentials{ GoogleClientID: cfg.GoogleOAuthClientID, GoogleClientSecret: cfg.GoogleOAuthClientSecret, FacebookClientID: cfg.FacebookOAuthClientID, FacebookClientSecret: cfg.FacebookOAuthClientSecret, }) if len(providers) > 0 { s.oauthH = handler.NewOAuthHandler( authSvc, cookieOpts, providers, []byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL, logger, ) } else { logger.Info("oauth: no providers configured — /auth/oauth/* will 404") } s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc) s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo) // Social graph: follows / blocks / reports, plus the notification // "plugin" seam (persists rows + emails today; websocket/push later). // All share the same Postgres pool + mailer. Reports reuse the // feedback admin recipients. followRepo := repository.NewFollowRepo(s.pg) blockRepo := repository.NewBlockRepo(s.pg) reportRepo := repository.NewReportRepo(s.pg) notifRepo := repository.NewNotificationRepo(s.pg) rtClient := realtime.NewSoketiClient( cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret, cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger, ) pushRepo := repository.NewPushRepo(s.pg) pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger) s.pushH = handler.NewPushHandler(pushSender, pushRepo) notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL) // Sādhana reminders: a background worker nudges users (email + push + // websocket) to chant / post a quote at their local time. Shares the // notification seam above so all three channels fire. reminderRepo := repository.NewReminderRepo(s.pg) reminderSvc := service.NewReminderService(reminderRepo, notifSvc, logger, cfg.FrontendURL) s.reminderH = handler.NewReminderHandler(reminderSvc) wctx := s.workerCtx if wctx == nil { wctx = context.Background() } worker.NewReminderWorker(reminderSvc, logger).Run(wctx) graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger) s.relationH = handler.NewRelationshipHandler(graphSvc) s.notificationH = handler.NewNotificationHandler(notifSvc) s.postH = handler.NewPostHandler(postSvc, graphSvc) s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc) // Sādhana streak: daily chanting log + the growing-plant state. Reads // the chanting half from sadhna_logs and the posting half from the // existing posts table, so it only needs its own repo plus PostRepo + // UserRepo (for the rounds target). sadhnaRepo := repository.NewSadhnaRepo(s.pg) rewardRepo := repository.NewRewardRepo(s.pg) // Sync the reward catalog from the embedded rewards.json (idempotent). if err := rewardRepo.Seed(context.Background()); err != nil { logger.Warn("sadhna: could not seed reward catalog", zap.Error(err)) } sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger) s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc) // Feedback + stats — share Postgres with the rest of /api/v1 and // the existing mailer instance. feedbackRepo := repository.NewFeedbackRepo(s.pg) statsRepo := repository.NewStatsRepo(s.pg) feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger) s.feedbackH = handler.NewFeedbackHandler(feedbackSvc) s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc) } // initShortener wires Postgres + Redis + the shorturl handler/workers. // On connection failure it logs and leaves the components nil — the handler // short-circuits to 503 so the rest of the BFF keeps working. func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) { ctx, cancel := context.WithCancel(context.Background()) s.workerCancel = cancel s.workerCtx = ctx pg, err := repository.NewPostgres(ctx, cfg.PostgresURI, logger) if err != nil { logger.Warn("URL shortener: Postgres unavailable — /share endpoints will return 503", zap.Error(err)) return } s.pg = pg migrationsDir, err := filepath.Abs("migrations") if err == nil { if err := pg.RunMigrations(ctx, migrationsDir); err != nil { logger.Warn("URL shortener: migrations failed", zap.Error(err)) } } rd, err := repository.NewRedis(ctx, cfg.RedisURI, logger) if err != nil { logger.Warn("URL shortener: Redis unavailable — /share endpoints will return 503", zap.Error(err)) return } s.redis = rd shortRepo := repository.NewShortURLRepo(pg) queue := shorturl.NewQueue(rd.Client) s.shortURLH = handler.NewShortURLHandler(shortRepo, queue, logger) w := worker.NewShortURLWorker(shortRepo, queue, logger) w.Run(ctx) } // Router returns the chi router. func (s *Server) Router() http.Handler { return s.router } // Close shuts down all connections gracefully. func (s *Server) Close() { if s.workerCancel != nil { s.workerCancel() } if err := s.mongo.Close(); err != nil { s.logger.Error("failed to close MongoDB connection", zap.Error(err)) } if s.pg != nil { s.pg.Close() } if s.redis != nil { if err := s.redis.Close(); err != nil { s.logger.Error("failed to close Redis connection", zap.Error(err)) } } s.logger.Info("all connections closed") } // authUnavailable is the 503 fallback used when Postgres is down and the // auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL // returning a structured error rather than 404. func (s *Server) authUnavailable(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"success":false,"error":{"code":503,"message":"auth service unavailable"}}`)) } // injectBookCode is a middleware that sets "code" in chi's URL params // for explicit sub-routes like /books/sb and /books/cc where {code} // isn't a URL param but a fixed path prefix. func injectBookCode(code string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rctx := chi.RouteContext(r.Context()) rctx.URLParams.Add("code", code) next.ServeHTTP(w, r) }) } } func (s *Server) setupRouter() { r := chi.NewRouter() // Global middleware r.Use(chiMiddleware.RequestID) r.Use(chiMiddleware.RealIP) r.Use(middleware.Logger(s.logger)) r.Use(chiMiddleware.Recoverer) r.Use(chiMiddleware.Compress(5)) // CORS r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{s.cfg.FrontendURL}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"}, ExposedHeaders: []string{"X-Request-ID"}, AllowCredentials: true, MaxAge: 300, })) // Health check r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) }) // Public ops snapshot — aggregate counts + recent feedback rendered // as HTML. Lives outside /api/v1 because it returns HTML, not JSON. // Currently unauthenticated by product decision; gate here if that // ever needs to change. if s.statsH != nil { r.Get("/stats", s.statsH.Render) // One-way per-row feedback actions — POST so a refresh of /stats // can't replay them. Both live at the same public access level as // /stats itself (gate them together if that ever changes). r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback) r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback) } // API v1 r.Route("/api/v1", func(r chi.Router) { // Books r.Route("/books", func(r chi.Router) { r.Get("/", s.bookH.ListBooks) // --- Verse books with divisions --- // SB — canto divisions r.Route("/sb", func(r chi.Router) { r.Use(injectBookCode("sb")) r.Get("/", s.bookH.GetBook) r.Get("/{division}", s.bookH.GetDivisionInfo) r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex) r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses) r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse) }) // CC — līlā divisions r.Route("/cc", func(r chi.Router) { r.Use(injectBookCode("cc")) r.Get("/", s.bookH.GetBook) r.Get("/{division}", s.bookH.GetDivisionInfo) r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex) r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses) r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse) }) // --- Verse books without divisions --- // BG, SB1962: pure verse books for _, code := range []string{"bg", "sb1962"} { code := code // capture for closure r.Route("/"+code, func(r chi.Router) { r.Use(injectBookCode(code)) r.Get("/", s.bookH.GetBook) r.Get("/{chapter}", s.bookH.GetChapterVerseIndex) r.Get("/{chapter}/verses", s.bookH.GetChapterVerses) r.Get("/{chapter}/{verse}", s.bookH.GetVerse) }) } // --- Verse + prose intro books (no division) --- // BS, MM, NBS: have both verses and prose intro segments for _, code := range []string{"bs", "mm", "nbs"} { code := code r.Route("/"+code, func(r chi.Router) { r.Use(injectBookCode(code)) r.Get("/", s.bookH.GetBook) r.Get("/chapters", s.bookH.ListProseChapters) r.Get("/chapters/{slug}", s.bookH.GetProseChapter) r.Get("/{chapter}", s.bookH.GetChapterVerseIndex) r.Get("/{chapter}/verses", s.bookH.GetChapterVerses) r.Get("/{chapter}/{verse}", s.bookH.GetVerse) }) } // --- Verse + prose intro books with special slugs --- // ISO, NOI: mantras/texts accessed by slug, plus prose intros for _, code := range []string{"iso", "noi"} { code := code r.Route("/"+code, func(r chi.Router) { r.Use(injectBookCode(code)) r.Get("/", s.bookH.GetBook) r.Get("/chapters", s.bookH.ListProseChapters) r.Get("/chapters/{slug}", s.bookH.GetProseChapter) r.Get("/{chapter}", s.bookH.GetChapterVerseIndex) r.Get("/{chapter}/verses", s.bookH.GetChapterVerses) r.Get("/{chapter}/{verse}", s.bookH.GetVerse) }) } // --- Volume-based prose book --- // KB: 2 volumes, chapters within each volume r.Route("/kb", func(r chi.Router) { r.Use(injectBookCode("kb")) r.Get("/", s.bookH.GetBook) r.Get("/{volume}", s.bookH.ListVolumeChapters) r.Get("/{volume}/{slug}", s.bookH.GetVolumeChapter) }) // --- Simple prose books --- // BBD, BK, CBHC, DS, EJ, EK, GG, HKH, LCL, LOB, MG, MOG, NOD, // OCHKM, OWK, PK, POY, PQPA, RV, SSR, SVA, TLC, TLK, TQK, TTP, VA for _, code := range []string{ "bbd", "bk", "cbhc", "ds", "ej", "ek", "gg", "hkh", "lcl", "lob", "mg", "mog", "nod", "ochkm", "owk", "pk", "poy", "pqpa", "rv", "ssr", "sva", "tlc", "tlk", "tqk", "ttp", "va", } { code := code r.Route("/"+code, func(r chi.Router) { r.Use(injectBookCode(code)) r.Get("/", s.bookH.GetBook) r.Get("/{slug}", s.bookH.GetProseChapter) r.Get("/chapters", s.bookH.ListProseChapters) r.Get("/chapters/{slug}", s.bookH.GetProseChapter) }) } }) // Letters r.Route("/letters", func(r chi.Router) { r.Get("/", s.letterH.Count) r.Get("/years", s.letterH.GetYears) r.Get("/recipients", s.letterH.GetRecipients) r.Get("/locations", s.letterH.GetLocations) r.Get("/year/{year}", s.letterH.GetByYear) r.Get("/recipient/{name}", s.letterH.GetByRecipient) r.Get("/location/{name}", s.letterH.GetByLocation) r.Get("/{slug}", s.letterH.GetBySlug) }) // Essays r.Route("/essays", func(r chi.Router) { r.Get("/", s.essayH.List) r.Get("/{slug}", s.essayH.GetBySlug) }) // Glossary r.Route("/glossary", func(r chi.Router) { r.Get("/", s.glossaryH.List) r.Get("/letters", s.glossaryH.GetLetterCounts) r.Get("/letter/{letter}", s.glossaryH.GetByLetter) r.Get("/letter/{letter}/terms", s.glossaryH.GetTermsByLetter) r.Get("/term/{term}", s.glossaryH.GetByTerm) }) // Disciples r.Route("/disciples", func(r chi.Router) { r.Get("/", s.discipleH.List) r.Get("/count", s.discipleH.Count) r.Get("/letters", s.discipleH.LetterCounts) r.Get("/suffixes", s.discipleH.SuffixCounts) r.Get("/search", s.discipleH.Search) }) // Journals r.Route("/journals", func(r chi.Router) { r.Get("/", s.journalH.ListJournals) r.Get("/{book}", s.journalH.GetJournal) r.Get("/{book}/chapters", s.journalH.ListChapters) r.Get("/{book}/chapters/{slug}", s.journalH.GetChapter) }) // Magazines r.Route("/magazines", func(r chi.Router) { r.Get("/", s.magazineH.ListMagazines) r.Get("/{book}", s.magazineH.GetMagazine) r.Get("/{book}/issues", s.magazineH.ListIssues) r.Get("/{book}/issues/{slug}", s.magazineH.GetIssue) }) // Other Documents r.Route("/other-documents", func(r chi.Router) { r.Get("/", s.otherDocH.ListDocuments) r.Get("/{book}", s.otherDocH.GetDocument) r.Get("/{book}/chapters", s.otherDocH.ListChapters) r.Get("/{book}/chapters/{slug}", s.otherDocH.GetChapter) }) // Transcriptions r.Route("/transcriptions", func(r chi.Router) { r.Get("/", s.transcriptionH.Count) r.Get("/years", s.transcriptionH.GetYears) r.Get("/places", s.transcriptionH.GetPlaces) r.Get("/types", s.transcriptionH.GetTypeDistribution) r.Get("/year/{year}", s.transcriptionH.GetByYear) r.Get("/place/{place}", s.transcriptionH.GetByPlace) r.Get("/type/{type}", s.transcriptionH.GetByType) r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent) r.Get("/{slug}", s.transcriptionH.GetBySlug) }) // Search (Typesense) r.Get("/search", s.searchH.Search) // --- Auth --- // Public endpoints (no JWT required) for sign-up, log-in, password reset, // and email verification. The /auth/me + /auth/resend-verification routes // require a session and live under the protected block below. r.Route("/auth", func(r chi.Router) { if s.authH == nil { r.HandleFunc("/*", s.authUnavailable) return } r.Post("/signup", s.authH.SignUp) r.Post("/login", s.authH.LogIn) r.Post("/verify-login-otp", s.authH.VerifyLoginOTP) r.Post("/logout", s.authH.LogOut) r.Post("/forgot-password", s.authH.ForgotPassword) r.Post("/reset-password", s.authH.ResetPassword) r.Post("/verify-email", s.authH.VerifyEmail) // OAuth (Google, Facebook, …). GET endpoints because the browser // navigates directly to /start (top-level redirect) and the // provider redirects back to /callback. Both flows set cookies // and 302 — they never return JSON. if s.oauthH != nil { r.Get("/oauth/{provider}/start", s.oauthH.Start) r.Get("/oauth/{provider}/callback", s.oauthH.Callback) } r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Get("/me", s.authH.Me) r.Get("/linked-providers", s.authH.LinkedProviders) r.Post("/email", s.authH.SetEmail) r.Post("/change-password", s.authH.ChangePassword) r.Post("/resend-verification", s.authH.ResendVerification) // Link-provider flow — requires the caller to be signed // in; the state encodes their id so the callback links to // THIS user instead of running the normal sign-in path. // The callback itself is the shared /oauth/{provider}/callback // above — the state's link_user_id field switches behaviour. if s.oauthH != nil { r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart) } }) }) // User profile — mixed public-read + protected writes. r.Route("/users", func(r chi.Router) { if s.userH == nil { r.HandleFunc("/*", s.authUnavailable) return } // Public read — search by handle / name. Returns a // sanitized payload (no email / role / plan / etc.). r.Get("/search", s.userH.Search) // Protected writes — profile edit, avatar presign, // reading-prefs roaming. r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Patch("/me", s.userH.UpdateProfile) r.Post("/me/avatar-presign", s.userH.AvatarPresign) // Reading prefs roam with the account so theme/palette/ // font picks follow the user across devices. Body shape // is owned by the frontend; the server treats it as an // opaque JSON object. r.Get("/me/reading-prefs", s.userH.GetReadingPrefs) r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs) }) }) // --- Social: posts --- r.Route("/posts", func(r chi.Router) { if s.postH == nil { r.HandleFunc("/*", s.authUnavailable) return } // Public read — anonymous-OK but populates viewer_id when a // session cookie is present, so the response can include // per-viewer fields like `viewer_has_reshared`. r.Group(func(r chi.Router) { r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Get("/{id}", s.postH.Get) }) // Protected — create / delete / image presign / "my posts" / // reshare actions. r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Post("/", s.postH.Create) r.Delete("/{id}", s.postH.Delete) r.Put("/{id}/images", s.postH.ReplaceImages) r.Get("/mine", s.postH.ListMine) r.Post("/image-presign", s.postH.ImagePresign) // Reshare is auth-required: anonymous users can't claim // to have reshared anything. The action is idempotent so // double-clicks don't 4xx. r.Post("/{id}/reshare", s.postH.Reshare) r.Delete("/{id}/reshare", s.postH.Unreshare) // Report a post (category + optional reason) → admin email. r.Post("/{id}/report", s.postH.Report) }) }) // --- Social: bookmarks (all protected; bookmarks are private by design) --- r.Route("/bookmarks", func(r chi.Router) { if s.bookmarkH == nil { r.HandleFunc("/*", s.authUnavailable) return } r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Post("/", s.bookmarkH.Create) r.Get("/", s.bookmarkH.List) r.Delete("/{id}", s.bookmarkH.Delete) }) // --- Social: collections --- // The single-collection GET is the only route that may be hit // anonymously (public collections); everything else needs auth. // OptionalAuth on the GET lets us populate `viewer_has_reshared` // on each post in the collection's carousel when a viewer is // signed in, without rejecting anonymous traffic. r.Route("/collections", func(r chi.Router) { if s.collectionH == nil { r.HandleFunc("/*", s.authUnavailable) return } r.Group(func(r chi.Router) { r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Get("/{id}", s.collectionH.Get) }) r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Post("/", s.collectionH.Create) r.Get("/", s.collectionH.ListMine) r.Patch("/{id}", s.collectionH.Update) r.Delete("/{id}", s.collectionH.Delete) r.Post("/{id}/items", s.collectionH.AddItem) r.Put("/{id}/items/order", s.collectionH.ReorderItems) r.Delete("/{id}/items/{post_id}", s.collectionH.RemoveItem) r.Get("/containing/{post_id}", s.collectionH.ContainingPost) r.Post("/cover-presign", s.collectionH.CoverPresign) }) }) // --- Public profile + feed --- // OptionalAuth populates viewer_id when a session cookie is // present, so the feed can carry `viewer_has_reshared` on each // post. Without a cookie the routes still respond — they just // return the anonymous shape (all-false). r.Route("/u", func(r chi.Router) { if s.profileH == nil { r.HandleFunc("/*", s.authUnavailable) return } // Public reads (anonymous-OK; OptionalAuth populates viewer_id // so Get can include the viewer's relationship + the feed can // carry viewer_has_reshared). r.Group(func(r chi.Router) { r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Get("/{handle}", s.profileH.Get) r.Get("/{handle}/feed", s.profileH.Feed) }) // Relationship actions — follow/block/report. All require a // signed-in viewer; each returns the recomputed relationship // + the target's follower/following counts. Follows are // request-based: POST /follow creates a PENDING request that // the target later accepts or rejects. if s.relationH != nil { r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Post("/{handle}/follow", s.relationH.Follow) r.Delete("/{handle}/follow", s.relationH.Unfollow) r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow) r.Post("/{handle}/follow/reject", s.relationH.RejectFollow) r.Delete("/{handle}/follower", s.relationH.RemoveFollower) r.Post("/{handle}/block", s.relationH.Block) r.Delete("/{handle}/block", s.relationH.Unblock) r.Post("/{handle}/report", s.relationH.Report) }) } }) // URL Shortener — Postgres-backed, Redis-queued. // The handler short-circuits to 503 if Postgres or Redis are unavailable. r.Route("/share", func(r chi.Router) { if s.shortURLH != nil { r.Post("/", s.shortURLH.Create) r.Get("/{code}", s.shortURLH.Resolve) } else { stub := func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte(`{"success":false,"error":{"code":503,"message":"url shortener unavailable"}}`)) } r.Post("/", stub) r.Get("/{code}", stub) } }) // --- Feedback --- // OptionalAuth so authed callers' user_id is captured // automatically (handler reads middleware.UserID), and // anonymous callers are still accepted (they supply // contact_email instead). r.Group(func(r chi.Router) { if s.feedbackH == nil { r.HandleFunc("/feedback", s.authUnavailable) return } r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) r.Post("/feedback", s.feedbackH.Submit) }) // Notifications — the signed-in user's in-app feed + unread badge. // Rows are persisted on every follow/accept event; websocket + push // delivery will layer on later. All protected. r.Route("/notifications", func(r chi.Router) { if s.notificationH == nil { r.HandleFunc("/*", s.authUnavailable) return } r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Get("/", s.notificationH.List) r.Get("/unread-count", s.notificationH.UnreadCount) r.Post("/read", s.notificationH.MarkAllRead) }) // --- Web Push --- VAPID key + per-user subscription management. r.Route("/push", func(r chi.Router) { if s.pushH == nil { r.HandleFunc("/*", s.authUnavailable) return } r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Get("/key", s.pushH.Key) r.Post("/subscribe", s.pushH.Subscribe) r.Post("/unsubscribe", s.pushH.Unsubscribe) }) // --- Sādhana streak --- daily chanting log + growing-plant state. // All endpoints require a signed-in user. r.Route("/sadhna", func(r chi.Router) { if s.sadhnaH == nil { r.HandleFunc("/*", s.authUnavailable) return } r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Get("/dashboard", s.sadhnaH.Dashboard) r.Get("/history", s.sadhnaH.History) r.Post("/chant", s.sadhnaH.SaveChant) r.Get("/rewards", s.sadhnaH.Rewards) r.Post("/rewards/ack", s.sadhnaH.AckRewards) if s.reminderH != nil { r.Get("/reminders", s.reminderH.GetPrefs) r.Put("/reminders", s.reminderH.UpdatePrefs) r.Post("/timezone", s.reminderH.SetTimezone) } }) }) s.router = r }