feat: quiz and rbac
This commit is contained in:
+114
-45
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user