feat: quiz and rbac

This commit is contained in:
Krispire Admin
2026-10-15 19:43:17 +05:30
parent 7d7ba2a002
commit 4b38047c26
26 changed files with 3270 additions and 75 deletions
+114 -45
View File
@@ -12,6 +12,7 @@ import (
"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/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/internal/worker"
@@ -78,6 +79,9 @@ type Server struct {
statsH *handler.StatsHandler
adminH *handler.AdminHandler
broadcastH *handler.BroadcastHandler
staffRepo *repository.StaffRepo
staffH *handler.StaffHandler
quizH *handler.QuizHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer
@@ -224,6 +228,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
// target title and kind.
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
// 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.
@@ -344,7 +352,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// 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)
// Admin RBAC: who is staff, with which permissions.
s.staffRepo = repository.NewStaffRepo(s.pg)
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
// Broadcasts — admin announcements shown once per user. No notification
// rows: the modal is the surface, and push plus the realtime nudge only
@@ -479,8 +490,14 @@ func (s *Server) setupRouter() {
// CORS — the main frontend plus, when configured, the admin panel (a
// separate origin). Empty origins are filtered so the list never widens
// unintentionally.
adminURL := s.cfg.AdminURL
if adminURL == "" && s.cfg.Env == "development" {
// The admin app's dev server (`next dev -p 3001`), so local admin
// work needs no extra config. Production must set ADMIN_URL.
adminURL = "http://localhost:3001"
}
allowedOrigins := []string{}
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
for _, o := range []string{s.cfg.FrontendURL, adminURL} {
if o != "" {
allowedOrigins = append(allowedOrigins, o)
}
@@ -708,6 +725,23 @@ func (s *Server) setupRouter() {
// Vāṇī of the Day — public daily content pick for the home page.
r.Get("/vani/today", s.vaniH.Today)
// Quizzes. Browsing is open to signed-out readers (they see that a
// quiz exists); taking one, and seeing results, needs an account.
r.Route("/quizzes", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/", s.quizH.List)
r.Get("/for", s.quizH.ForPath)
r.Get("/{id}", s.quizH.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/{id}/start", s.quizH.Start)
r.Post("/{id}/answer", s.quizH.Answer)
r.Get("/{id}/result", s.quizH.Result)
})
})
// --- Auth ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes
@@ -1150,52 +1184,87 @@ func (s *Server) setupRouter() {
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)
// Staff only past this point; each route then names the one
// permission it needs. A route without a permission is reachable
// by every staff member, so only /me is left bare.
r.Use(middleware.RequireStaff(s.staffRepo))
can := func(perm string) chi.Router {
return r.With(middleware.RequirePermission(perm))
}
// Announcements composed and published from the admin panel.
r.Get("/vani", s.vaniH.Schedule)
r.Post("/vani", s.vaniH.Set)
r.Post("/vani/by-url", s.vaniH.SetByURL)
r.Delete("/vani/{day}", s.vaniH.Clear)
r.Get("/me", s.staffH.Me)
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
r.Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
r.Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
// Staff and roles — superadmins only.
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
r.Get("/broadcasts", s.broadcastH.List)
r.Post("/broadcasts", s.broadcastH.Create)
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
r.Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
r.Delete("/broadcasts/{id}", s.broadcastH.Delete)
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)
can("stats:read").Get("/stats", s.adminH.Stats)
can("users:read").Get("/users", s.adminH.ListUsers)
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
can("users:read").Get("/disciples", s.adminH.ListDisciples)
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
can("vani:read").Get("/vani", s.vaniH.Schedule)
can("vani:write").Post("/vani", s.vaniH.Set)
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
can("collections:read").Get("/collections", s.adminH.ListCollections)
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
can("reports:read").Get("/reports", s.adminH.ListReports)
can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
can("quizzes:read").Get("/quizzes", s.quizH.AdminList)
can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet)
can("quizzes:write").Post("/quizzes", s.quizH.Create)
can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update)
can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish)
can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish)
can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete)
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
})
})