Files
2026-06-16 14:47:45 +05:30

1012 lines
39 KiB
Go

package server
import (
"context"
"net/http"
"path/filepath"
"time"
"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/response"
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
"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
sitemapH *handler.SitemapHandler
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
oauthH *handler.OAuthHandler
userH *handler.UserHandler
postH *handler.PostHandler
commentH *handler.CommentHandler
bookmarkH *handler.BookmarkHandler
collectionH *handler.CollectionHandler
profileH *handler.ProfileHandler
relationH *handler.RelationshipHandler
soketiWebhookH *handler.SoketiWebhookHandler
notificationH *handler.NotificationHandler
sadhnaH *handler.SadhnaHandler
pushH *handler.PushHandler
reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler
statsH *handler.StatsHandler
adminH *handler.AdminHandler
// 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)
sitemapRepo := repository.NewSitemapRepo(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),
sitemapH: handler.NewSitemapHandler(sitemapRepo),
}
// 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)
authSvc.SetRevalidator(revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger))
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)
// Likes: membership in Postgres, count delta batched through Redis
// (s.redis may be nil — LikeRepo then applies counts directly).
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
mentionRepo := repository.NewMentionRepo(s.pg)
commentRepo := repository.NewCommentRepo(s.pg, logger)
// Per-user "already served" set so the home feed never repeats posts
// across pages (nil-safe — falls back to offset paging without Redis).
feedRepo := repository.NewFeedRepo(s.redis, logger)
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
// Soketi signs webhooks with the app secret; on channel_vacated for a
// per-user notifications channel we evict that user's feed seen-set.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
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)
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Wire the notification seam into the post service so a new like notifies
// the author (push + realtime; not the liker themselves).
postSvc.SetNotifier(notifSvc)
// Comments: comment / reply / like / pin / report over the same Postgres
// pool, with the notification seam so new comments notify the post author
// and replies notify the comment they answer.
commentSvc := service.NewCommentService(commentRepo)
commentSvc.SetNotifier(notifSvc)
s.commentH = handler.NewCommentHandler(commentSvc)
// 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, notifPrefsRepo, notifSvc, logger, cfg.FrontendURL)
s.reminderH = handler.NewReminderHandler(reminderSvc)
wctx := s.workerCtx
if wctx == nil {
wctx = context.Background()
}
worker.NewReminderWorker(reminderSvc, logger).Run(wctx)
// Likes worker: folds the pending Redis like-deltas into posts.likes_count
// every 15 minutes (no-op when Redis is unavailable).
worker.NewLikesWorker(likeRepo, 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, likeRepo, mentionRepo, commentRepo)
// 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)
// Admin panel — read-mostly listing + notify/reply/resolve actions.
// Reuses the notification service (delivery) and mailer (feedback reply).
// Gated by RequireAuth + RequireAdmin in the router.
adminRepo := repository.NewAdminRepo(s.pg)
adminSvc := service.NewAdminService(adminRepo, notifSvc, mail, logger, cfg.FrontendURL)
s.adminH = handler.NewAdminHandler(adminSvc)
}
// 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")
}
// sitemapUsers — GET /api/v1/sitemap/users → { "urls": ["/u/handle", …] }.
// Public-profile sitemap. Reads userRepo at request time (it's wired only
// after Postgres init); degrades to an empty list when Postgres is down.
func (s *Server) sitemapUsers(w http.ResponseWriter, r *http.Request) {
if s.userRepo == nil {
response.JSON(w, http.StatusOK, map[string]any{"urls": []string{}})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
handles, err := s.userRepo.AllHandles(ctx)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not build user sitemap")
return
}
urls := make([]string, 0, len(handles))
for _, h := range handles {
urls = append(urls, "/u/"+h)
}
response.JSON(w, http.StatusOK, map[string]any{"urls": urls})
}
// 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 — the main frontend plus, when configured, the admin panel (a
// separate origin). Empty origins are filtered so the list never widens
// unintentionally.
allowedOrigins := []string{}
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
if o != "" {
allowedOrigins = append(allowedOrigins, o)
}
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
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) {
// Sitemap — flat list of public content URLs (consumed by the
// frontend's daily-cached sitemap routes). Public, read-only.
r.Get("/sitemap/content", s.sitemapH.GetContent)
// Public user profiles (Postgres) — separate from Mongo content.
r.Get("/sitemap/users", s.sitemapUsers)
// Books
r.Route("/books", func(r chi.Router) {
r.Get("/", s.bookH.ListBooks)
// --- Verse books with divisions ---
// SB — canto divisions (plus prose front-matter segments;
// the static /chapters routes take precedence over /{division}).
r.Route("/sb", func(r chi.Router) {
r.Use(injectBookCode("sb"))
r.Get("/", s.bookH.GetBook)
r.Get("/chapters", s.bookH.ListProseChapters)
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
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 (plus prose front-matter segments;
// the static /chapters routes take precedence over /{division}).
r.Route("/cc", func(r chi.Router) {
r.Use(injectBookCode("cc"))
r.Get("/", s.bookH.GetBook)
r.Get("/chapters", s.bookH.ListProseChapters)
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
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 ---
// SB1962: pure verse book
for _, code := range []string{"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) ---
// BG, BS, MM, NBS: verses plus prose front-matter segments
// (the static /chapters routes take precedence over /{chapter}).
for _, code := range []string{"bg", "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)
// Paginated "liked by" list — public, like the count itself.
r.Get("/{id}/likes", s.postH.ListLikes)
// Tagged users on a post — public (read-only for non-authors).
r.Get("/{id}/mentions", s.postH.ListMentions)
// Comments — public read (viewer_has_liked populated when signed in).
if s.commentH != nil {
r.Get("/{id}/comments", s.commentH.List)
}
})
// 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)
// Hide / unhide from timelines + public collections (owner-only).
r.Post("/{id}/hide", s.postH.Hide)
r.Delete("/{id}/hide", s.postH.Unhide)
r.Put("/{id}/images", s.postH.ReplaceImages)
r.Get("/mine", s.postH.ListMine)
r.Get("/hidden", s.postH.ListHidden)
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)
// Like / unlike — idempotent; membership is written now, the
// count is batched into likes_count via Redis.
r.Post("/{id}/like", s.postH.Like)
r.Delete("/{id}/like", s.postH.Unlike)
// Mentions: composer suggestions + author add/remove of tags.
r.Get("/mention-suggest", s.postH.MentionSuggest)
r.Post("/{id}/mentions", s.postH.AddMention)
r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention)
// Report a post (category + optional reason) → admin email.
r.Post("/{id}/report", s.postH.Report)
// Add a comment / reply (anyone signed in).
if s.commentH != nil {
r.Post("/{id}/comments", s.commentH.Create)
}
})
})
// --- Social: comments (replies, likes, pin, delete, report) ---
// Keyed by comment id; the post-scoped list/create live under /posts.
r.Route("/comments", func(r chi.Router) {
if s.commentH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
// Public read — a thread's replies (viewer_has_liked when signed in).
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}/replies", s.commentH.Replies)
})
// Protected — like / pin / delete / report.
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/{id}/like", s.commentH.Like)
r.Delete("/{id}/like", s.commentH.Unlike)
// Pin / unpin — post author only (enforced in the service).
r.Post("/{id}/pin", s.commentH.Pin)
r.Delete("/{id}/pin", s.commentH.Unpin)
// Delete — comment author or post author; removes the thread.
r.Delete("/{id}", s.commentH.Delete)
// Report a comment (category + optional reason).
r.Post("/{id}/report", s.commentH.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).
// Soketi webhooks — server-to-server, authenticated by the HMAC
// signature (not a session). channel_vacated on a per-user
// notifications channel evicts that user's feed seen-set.
if s.soketiWebhookH != nil {
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
}
// Home feed — the global, personalised feed of posts + its "new on
// the platform" discovery strip. Auth-required (the feed is
// per-viewer: ordering + relationship flags depend on the caller).
r.Route("/feed", func(r chi.Router) {
if s.postH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/", s.postH.Feed)
r.Get("/new-users", s.postH.FeedNewUsers)
// Clear the per-user seen-set (logout / page unload).
r.Post("/reset", s.postH.FeedReset)
})
})
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)
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
r.Get("/{handle}/followers", s.profileH.Followers)
r.Get("/{handle}/following", s.profileH.Following)
})
// 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)
r.Get("/prefs", s.notificationH.GetPrefs)
r.Put("/prefs", s.notificationH.UpdatePrefs)
// Legacy single-toggle endpoints — shims over /prefs, kept until
// old clients are gone.
r.Get("/email-prefs", s.notificationH.GetEmailPrefs)
r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs)
})
// --- 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)
}
})
// --- Admin panel --- every route gated by RequireAuth + RequireAdmin
// (role == "admin"), so the whole surface is invisible to ordinary
// accounts. Additive: no existing route or payload changes.
r.Route("/admin", func(r chi.Router) {
if s.adminH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Use(middleware.RequireAdmin(s.userRepo))
r.Get("/users", s.adminH.ListUsers)
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
r.Post("/users/{id}/ban", s.adminH.BanUser)
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
r.Get("/posts", s.adminH.ListPosts)
r.Post("/posts/{id}/hide", s.adminH.HidePost)
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
r.Delete("/posts/{id}", s.adminH.DeletePost)
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
r.Delete("/comments/{id}", s.adminH.DeleteComment)
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
r.Get("/collections", s.adminH.ListCollections)
r.Get("/feedback", s.adminH.ListFeedback)
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
r.Get("/reports", s.adminH.ListReports)
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
})
})
s.router = r
}