add app review system with validation pipeline and admin htmx UI
This commit is contained in:
226
portal/internal/api/handlers/admin.go
Normal file
226
portal/internal/api/handlers/admin.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Package handlers contains HTTP request handlers
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/omixlab/mosis-portal/internal/database"
|
||||
"github.com/omixlab/mosis-portal/internal/review"
|
||||
"github.com/omixlab/mosis-portal/internal/storage"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin operations
|
||||
type AdminHandler struct {
|
||||
db *database.DB
|
||||
store *storage.Storage
|
||||
review *review.Service
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new admin handler
|
||||
func NewAdminHandler(db *database.DB, store *storage.Storage) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
db: db,
|
||||
store: store,
|
||||
review: review.New(db),
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard returns the admin dashboard with stats
|
||||
func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
pending, approved, rejected, err := h.db.GetReviewStats(ctx)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"stats": map[string]int{
|
||||
"pending": pending,
|
||||
"approved": approved,
|
||||
"rejected": rejected,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewQueue lists versions pending review
|
||||
func (h *AdminHandler) ReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse pagination
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
versions, total, err := h.db.GetVersionsInReview(ctx, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"items": versions,
|
||||
"pagination": map[string]int{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"total_pages": (total + limit - 1) / limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewDetail returns details for a specific version under review
|
||||
func (h *AdminHandler) ReviewDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
|
||||
versionWithApp, err := h.db.GetVersionWithApp(ctx, versionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||
return
|
||||
}
|
||||
|
||||
// If package exists, run validation to get flags
|
||||
var validationResult *review.FullValidationResult
|
||||
if versionWithApp.Version.PackageURL != "" {
|
||||
packagePath := h.store.GetPackagePath(versionWithApp.Version.PackageURL)
|
||||
result, err := h.review.ValidatePackage(packagePath)
|
||||
if err == nil {
|
||||
validationResult = result
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"version": versionWithApp.Version,
|
||||
"app": versionWithApp.App,
|
||||
"developer": map[string]string{
|
||||
"name": versionWithApp.DeveloperName,
|
||||
"email": versionWithApp.DeveloperEmail,
|
||||
},
|
||||
"validation": validationResult,
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveVersion approves a version
|
||||
func (h *AdminHandler) ApproveVersion(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
|
||||
// Parse request body
|
||||
var req struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Notes are optional, continue with empty
|
||||
}
|
||||
|
||||
// Verify version exists and is in review
|
||||
version, err := h.db.GetVersion(ctx, versionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||
return
|
||||
}
|
||||
|
||||
if version.Status != "in_review" {
|
||||
Error(w, http.StatusBadRequest, "invalid_status", "Version is not in review")
|
||||
return
|
||||
}
|
||||
|
||||
// Approve the version
|
||||
if err := h.review.ApproveVersion(ctx, versionID, req.Notes); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Version approved and published",
|
||||
})
|
||||
}
|
||||
|
||||
// RejectVersion rejects a version
|
||||
func (h *AdminHandler) RejectVersion(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
|
||||
// Parse request body
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Reason == "" {
|
||||
Error(w, http.StatusBadRequest, "missing_reason", "Rejection reason is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify version exists and is in review
|
||||
version, err := h.db.GetVersion(ctx, versionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||
return
|
||||
}
|
||||
|
||||
if version.Status != "in_review" {
|
||||
Error(w, http.StatusBadRequest, "invalid_status", "Version is not in review")
|
||||
return
|
||||
}
|
||||
|
||||
// Reject the version
|
||||
feedback := &review.RejectionFeedback{
|
||||
Reason: req.Reason,
|
||||
Message: req.Message,
|
||||
CanResubmit: true,
|
||||
}
|
||||
if err := h.review.RejectVersion(ctx, versionID, feedback); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Version rejected",
|
||||
})
|
||||
}
|
||||
|
||||
// ValidatePackage runs validation on a package and returns results
|
||||
func (h *AdminHandler) ValidatePackage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
|
||||
// Get version with package info
|
||||
version, err := h.db.GetVersion(ctx, versionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||
return
|
||||
}
|
||||
|
||||
if version.PackageURL == "" {
|
||||
Error(w, http.StatusBadRequest, "no_package", "Version has no uploaded package")
|
||||
return
|
||||
}
|
||||
|
||||
// Run validation
|
||||
packagePath := h.store.GetPackagePath(version.PackageURL)
|
||||
result, err := h.review.ValidatePackage(packagePath)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "validation_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
|
||||
appHandler := handlers.NewAppHandler(db, store)
|
||||
storeHandler := handlers.NewStoreHandler(db, store)
|
||||
adminHandler := handlers.NewAdminHandler(db, store)
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -127,14 +128,15 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
// Admin routes (htmx UI) - requires auth
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
// Admin API routes (JSON responses)
|
||||
r.Route("/api/admin", func(r chi.Router) {
|
||||
r.Use(authMiddleware.RequireAuth)
|
||||
r.Get("/", handlers.NotImplemented)
|
||||
r.Get("/review-queue", handlers.NotImplemented)
|
||||
r.Get("/review/{versionID}", handlers.NotImplemented)
|
||||
r.Post("/review/{versionID}/approve", handlers.NotImplemented)
|
||||
r.Post("/review/{versionID}/reject", handlers.NotImplemented)
|
||||
r.Get("/stats", adminHandler.Dashboard)
|
||||
r.Get("/review-queue", adminHandler.ReviewQueue)
|
||||
r.Get("/review/{versionID}", adminHandler.ReviewDetail)
|
||||
r.Post("/review/{versionID}/approve", adminHandler.ApproveVersion)
|
||||
r.Post("/review/{versionID}/reject", adminHandler.RejectVersion)
|
||||
r.Get("/review/{versionID}/validate", adminHandler.ValidatePackage)
|
||||
})
|
||||
|
||||
// Web UI routes (htmx + Go templates)
|
||||
@@ -142,6 +144,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to initialize web handler: %v", err)
|
||||
} else {
|
||||
webHandler.SetStorage(store)
|
||||
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
|
||||
|
||||
// Public web pages
|
||||
@@ -163,6 +166,14 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
|
||||
// htmx partials
|
||||
r.Get("/partials/apps", webHandler.AppListPartial)
|
||||
|
||||
// Admin pages (htmx UI)
|
||||
r.Get("/admin/review-queue", webHandler.AdminReviewQueue)
|
||||
r.Get("/admin/review/{versionID}", webHandler.AdminReviewDetail)
|
||||
r.Get("/admin/partials/review-queue", webHandler.AdminReviewQueuePartial)
|
||||
r.Post("/admin/review/{versionID}/approve", webHandler.AdminApprove)
|
||||
r.Post("/admin/review/{versionID}/reject", webHandler.AdminReject)
|
||||
r.Get("/admin/review/{versionID}/validate", webHandler.AdminValidate)
|
||||
})
|
||||
|
||||
// Auth callback that sets session (after OAuth)
|
||||
|
||||
Reference in New Issue
Block a user