Files
spsays-bff/internal/server/server.go
T
2026-08-29 03:49:30 +05:30

1151 lines
45 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
// discipleRepo is held on the struct (unlike the other Mongo repos, which
// stay local to New) because initAuth re-builds discipleH with the
// Postgres userRepo once it exists — see the direct-disciple link feature.
discipleRepo *repository.DiscipleRepo
// 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
vaniH *handler.VaniHandler
sitemapH *handler.SitemapHandler
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
oauthH *handler.OAuthHandler
userH *handler.UserHandler
audioH *handler.AudioProgressHandler
postH *handler.PostHandler
commentH *handler.CommentHandler
bookmarkH *handler.BookmarkHandler
collectionH *handler.CollectionHandler
profileH *handler.ProfileHandler
relationH *handler.RelationshipHandler
soketiWebhookH *handler.SoketiWebhookHandler
broadcastAuthH *handler.BroadcastingAuthHandler
chatH *handler.ChatHandler
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,
discipleRepo: discipleRepo,
// Initialize handlers
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
// userRepo half is attached later in initAuth (Postgres isn't up yet).
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
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,
PrivateBucket: cfg.R2PrivateBucket,
})
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)
// Re-wire the disciple handler now that Postgres-backed users exist: its
// /disciples/linked route joins the Mongo disciple records to the platform
// accounts an admin has linked to them. Built in New() with a nil userRepo
// (Mongo is up before Postgres), so this is where it gets the other half.
if s.discipleRepo != nil {
s.discipleH = handler.NewDiscipleHandler(s.discipleRepo, userRepo)
}
// Audio playback resume points — per-user, per-file last position that
// lets the floating player pick up where it left off across devices.
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
// Vāṇī of the Day — one daily pick (prose/verse/transcript) for the home
// page, chosen from Mongo content and recorded in Postgres so it doesn't
// repeat within 90 days.
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
// 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,
)
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny — an unlisted channel is refused.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
handler.NotificationsRule(),
handler.ConversationRule(chatSvc.IsParticipant),
)
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, userRepo, 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, chatRepo, 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)
// 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, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, 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)
// Our RealIP (not chi's): a single trusted edge proxy (Dokploy's Traefik)
// appends the real client to the right of X-Forwarded-For, so we read the
// right-most entry. chi's RealIP trusts the spoofable left-most entry.
r.Use(middleware.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 {
// Read-only public snapshot. The feedback acknowledge/resolve actions
// moved to the admin panel (POST /api/v1/admin/feedback/{id}/...) — they
// must not be exposed on this unauthenticated page.
r.Get("/stats", s.statsH.Render)
}
// 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)
// Disciples with a platform account (admin-linked) — drives the
// highlight in the /disciples browser.
r.Get("/linked", s.discipleH.Linked)
})
// 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("/books", s.transcriptionH.GetBooks)
r.Get("/year/{year}", s.transcriptionH.GetByYear)
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
r.Get("/type/{type}", s.transcriptionH.GetByType)
r.Get("/book/{book}", s.transcriptionH.GetByBook)
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
r.Get("/{slug}", s.transcriptionH.GetBySlug)
})
// Search (Typesense)
r.Get("/search", s.searchH.Search)
// Vāṇī of the Day — public daily content pick for the home page.
r.Get("/vani/today", s.vaniH.Today)
// --- 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)
// Audio player resume points roam with the account too, so a
// lecture picks up where you left off on any device. Schema is
// owned by the frontend; the server stores opaque key/position
// rows and merges newest-wins.
r.Get("/me/audio-progress", s.audioH.Get)
r.Put("/me/audio-progress", s.audioH.Put)
r.Delete("/me/audio-progress", s.audioH.Delete)
})
})
// --- 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)
}
// Chat — inbox, threads, messages. Every route requires a session;
// membership is re-checked per request in the service, so a
// conversation id on its own grants nothing.
if s.chatH != nil {
r.Route("/chat", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox)
r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
r.Post("/link-preview", s.chatH.LinkPreview)
r.Get("/settings", s.chatH.Settings)
r.Patch("/settings", s.chatH.UpdateSettings)
r.Get("/attachments/{attachmentID}", s.chatH.Attachment)
r.Route("/conversations/{id}", func(r chi.Router) {
r.Get("/", s.chatH.Detail)
r.Patch("/", s.chatH.UpdateGroup)
r.Post("/members", s.chatH.AddMembers)
r.Delete("/members/{handle}", s.chatH.RemoveMember)
r.Patch("/members/{handle}", s.chatH.SetMemberRole)
r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead)
r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute)
r.Post("/attachments/presign", s.chatH.PresignAttachment)
r.Post("/avatar/presign", s.chatH.PresignGroupAvatar)
r.Get("/avatar", s.chatH.GroupAvatar)
r.Get("/gallery", s.chatH.Gallery)
r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star)
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward)
})
})
}
// Pusher channel authorization — pusher-js posts here (as its
// `authEndpoint`) before subscribing to a private-/presence- channel.
// Session-authenticated: the signature is only minted for a caller
// who has proven who they are and passed the channel's rule.
if s.broadcastAuthH != nil {
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/broadcasting/auth", s.broadcastAuthH.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)
})
})
// Caller's pending incoming follow requests (the notifications
// "Friend Requests" tab). Caller-scoped, so it lives outside /u/{handle}.
if s.profileH != nil {
r.With(middleware.RequireAuth(s.authIssuer, s.userRepo)).
Get("/follow-requests", s.profileH.IncomingFollowRequests)
}
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)
r.HandleFunc("/feedback/mine", s.authUnavailable)
return
}
// Submit — OptionalAuth (anonymous callers allowed).
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Post("/feedback", s.feedbackH.Submit)
})
// My submissions — RequireAuth. Lists the caller's own rows
// with status + admin reply for the /settings/feedbacks page.
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/feedback/mine", s.feedbackH.Mine)
})
})
// 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("/stats", s.adminH.Stats)
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.Post("/users/{id}/limits", s.adminH.SetUserLimits)
// Direct-disciple linking: mark an account as one of Śrīla
// Prabhupāda's initiated disciples (or clear the mark).
r.Get("/disciples", s.adminH.ListDisciples)
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
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.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
r.Get("/reports", s.adminH.ListReports)
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
r.Get("/short-urls", s.adminH.ListShortURLs)
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
})
})
s.router = r
}