chore: update admin apis

This commit is contained in:
Krispire Admin
2026-06-16 15:45:33 +05:30
parent a49bf7591e
commit 4c42d9a7f9
6 changed files with 70 additions and 67 deletions
+21
View File
@@ -169,6 +169,27 @@ func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed})
}
// AcknowledgeFeedback — POST /admin/feedback/{id}/acknowledge
func (h *AdminHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
err := h.svc.AcknowledgeFeedback(r.Context(), chi.URLParam(r, "id"))
// Already-acknowledged is a no-op success from the client's perspective.
if err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
response.Error(w, http.StatusInternalServerError, "could not acknowledge feedback")
return
}
response.JSON(w, http.StatusOK, map[string]any{"acknowledged": true})
}
// ResolveFeedback — POST /admin/feedback/{id}/resolve
func (h *AdminHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
err := h.svc.ResolveFeedback(r.Context(), chi.URLParam(r, "id"))
if err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
response.Error(w, http.StatusInternalServerError, "could not resolve feedback")
return
}
response.JSON(w, http.StatusOK, map[string]any{"resolved": true})
}
// ResolveReport — POST /admin/reports/{type}/{id}/resolve
func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
reportType := chi.URLParam(r, "type")
+8 -46
View File
@@ -1,14 +1,11 @@
package handler
import (
"errors"
"html/template"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
)
// StatsHandler renders a single-page HTML snapshot of the system —
@@ -17,17 +14,15 @@ import (
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
//
// It also handles the "Mark resolved" form POST per feedback row, which
// stamps resolved_at (one-way) and fires the author thank-you email via
// the feedback service.
// It is READ-ONLY: feedback acknowledge/resolve actions live in the
// authenticated admin panel, never on this public page.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
svc *service.FeedbackService
}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback}
}
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
@@ -55,33 +50,6 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
}
}
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
// the row acknowledged (one-way) and emails the author that we've seen
// it and started work, then 303-redirects back to /stats. Already-
// acknowledged or unknown ids redirect silently.
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
// the row resolved (one-way) and fires the author thank-you email, then
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
// resolved or unknown ids redirect silently — the desired end state is
// already true.
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
@@ -115,6 +83,7 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom:
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
.pending-badge { display: inline-flex; align-items: center; font-size: 0.78rem; font-weight: 600; color: #999 }
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.resolve-btn:hover { background: #196b40 }
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
@@ -168,17 +137,10 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
{{if .ResolvedAt}}
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
{{else if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
{{if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
<button type="submit" class="ack-btn">Acknowledge</button>
</form>
{{end}}
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
<button type="submit" class="resolve-btn">Mark resolved</button>
</form>
<span class="pending-badge">Pending</span>
{{end}}
</div>
</div>
+6 -4
View File
@@ -17,6 +17,7 @@ type AdminUser struct {
Plan string `json:"plan"`
EmailVerifiedAt *time.Time `json:"email_verified_at"`
BannedAt *time.Time `json:"banned_at"`
PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"`
}
@@ -54,10 +55,11 @@ type AdminFeedback struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at"`
RepliedAt *time.Time `json:"replied_at"`
ContactEmail *string `json:"contact_email"`
CreatedAt time.Time `json:"created_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at"`
ResolvedAt *time.Time `json:"resolved_at"`
RepliedAt *time.Time `json:"replied_at"`
ReplyMessage *string `json:"reply_message"`
AuthorHandle *string `json:"author_handle"`
AuthorName *string `json:"author_name"`
+11 -8
View File
@@ -33,12 +33,15 @@ func clampLimit(limit int) int {
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
limit = clampLimit(limit)
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at,
SELECT u.id, u.email, u.handle, u.name, u.avatar_url, u.role, u.plan,
u.email_verified_at, u.banned_at,
(SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count,
u.created_at,
COUNT(*) OVER() AS total
FROM users
WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%'
OR COALESCE(email, '') ILIKE '%'||$1||'%')
ORDER BY created_at DESC
FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
OR COALESCE(u.email, '') ILIKE '%'||$1||'%')
ORDER BY u.created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list users: %w", err)
@@ -50,7 +53,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
for rows.Next() {
var u model.AdminUser
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.CreatedAt, &total); err != nil {
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err)
}
out = append(out, u)
@@ -146,7 +149,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
f.resolved_at, f.replied_at, f.reply_message,
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
u.handle, u.name, u.email,
COUNT(*) OVER() AS total
FROM feedback f
@@ -166,7 +169,7 @@ func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offs
for rows.Next() {
var f model.AdminFeedback
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
}
+7 -7
View File
@@ -284,13 +284,13 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
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)
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, notifSvc, mail, logger, cfg.FrontendURL)
adminSvc := service.NewAdminService(adminRepo, notifSvc, feedbackSvc, mail, logger, cfg.FrontendURL)
s.adminH = handler.NewAdminHandler(adminSvc)
}
@@ -441,12 +441,10 @@ func (s *Server) setupRouter() {
// 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)
// 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
@@ -1002,6 +1000,8 @@ func (s *Server) setupRouter() {
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)
})
+17 -2
View File
@@ -23,13 +23,28 @@ var ErrAdminUserNotFound = errors.New("user not found")
type AdminService struct {
repo *repository.AdminRepo
notif *NotificationService
feedback *FeedbackService
mail mailer.Mailer
logger *zap.Logger
frontendURL string
}
func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL}
func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, feedback *FeedbackService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
return &AdminService{repo: repo, notif: notif, feedback: feedback, mail: mail, logger: logger, frontendURL: frontendURL}
}
// AcknowledgeFeedback marks a feedback row acknowledged and emails the author
// that we've seen it. Reuses FeedbackService so the email is identical to the
// (now removed) public /stats action. Returns ErrAlreadyAcknowledged when the
// row was already acknowledged.
func (s *AdminService) AcknowledgeFeedback(ctx context.Context, id string) error {
return s.feedback.Acknowledge(ctx, id)
}
// ResolveFeedback marks a feedback row resolved and emails the author a
// thank-you. Returns ErrAlreadyResolved when the row was already resolved.
func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
return s.feedback.Resolve(ctx, id)
}
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {