Ctrl K
ComunidadGuías…

internal/adminapi/verification.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package adminapi

import (
	"net/http"
	"strconv"
	"strings"
	"time"

	"telesrv/internal/admin"
	"telesrv/internal/domain"
)

// Official platform verification review over the admin API.
//
// These are the mirror routes of the panel's own endpoints: the panel reads the
// queue straight from PostgreSQL for speed, while an integration holding a scoped
// token reads it here. Decisions only ever travel this way, so the command
// journal and the status machine are enforced in one place.

// handleVerificationApplications is the review queue.
func (s *Server) handleVerificationApplications(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	filter := domain.VerificationApplicationFilter{
		TargetType: domain.VerificationTargetType(strings.TrimSpace(query.Get("target_type"))),
		Reviewer:   strings.TrimSpace(query.Get("reviewer")),
		Query:      query.Get("q"),
	}
	if filter.TargetType != "" && !filter.TargetType.Valid() {
		writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationTargetInvalid, "invalid target_type")
		return
	}
	// status accepts a comma-separated list, so the queue view ("submitted,
	// in_review") is one request rather than two.
	for _, raw := range strings.Split(query.Get("status"), ",") {
		raw = strings.TrimSpace(raw)
		if raw == "" {
			continue
		}
		status := domain.VerificationStatus(raw)
		if !status.Valid() {
			writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationStatusInvalid, "invalid status "+raw)
			return
		}
		filter.Statuses = append(filter.Statuses, status)
	}
	limit, ok := optionalQueryInt(w, query, "limit")
	if !ok {
		return
	}
	filter.Limit = limit
	beforeID, ok := optionalQueryInt64(w, query, "before_id")
	if !ok {
		return
	}
	filter.BeforeID = beforeID
	items, err := s.svc.VerificationApplications(r.Context(), filter)
	if err != nil {
		writeVerificationError(w, err)
		return
	}
	applications := make([]map[string]any, 0, len(items))
	for _, item := range items {
		applications = append(applications, verificationApplicationResponse(item))
	}
	writeJSON(w, http.StatusOK, map[string]any{"applications": applications})
}

// handleVerificationApplication is one application with its history and the
// target as it looks right now.
func (s *Server) handleVerificationApplication(w http.ResponseWriter, r *http.Request) {
	id, ok := moderationPathID(w, r, "id")
	if !ok {
		return
	}
	app, err := s.svc.VerificationApplication(r.Context(), id)
	if err != nil {
		writeVerificationError(w, err)
		return
	}
	limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
	if !ok {
		return
	}
	events, err := s.svc.VerificationApplicationEvents(r.Context(), app.ID, limit)
	if err != nil {
		writeVerificationError(w, err)
		return
	}
	history := make([]map[string]any, 0, len(events))
	for _, event := range events {
		history = append(history, verificationEventResponse(event))
	}
	body := map[string]any{
		"application": verificationApplicationResponse(app),
		"events":      history,
	}
	// The snapshot is advisory: a target that vanished must not turn the audit
	// record into a 500, so a snapshot failure is reported next to the record
	// instead of replacing it.
	if target, err := s.svc.VerificationTargetSnapshot(r.Context(), app.TargetType, app.TargetID); err == nil {
		body["target"] = verificationTargetResponse(target)
	} else {
		body["target_error"] = err.Error()
	}
	writeJSON(w, http.StatusOK, body)
}

// handleVerificationCounts is the queue summary.
func (s *Server) handleVerificationCounts(w http.ResponseWriter, r *http.Request) {
	counts, err := s.svc.VerificationCounts(r.Context())
	if err != nil {
		writeVerificationError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"counts": verificationCountsResponse(counts)})
}

func (s *Server) handleClaimVerification(w http.ResponseWriter, r *http.Request) {
	id, ok := moderationPathID(w, r, "id")
	if !ok {
		return
	}
	var req admin.ClaimVerificationRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	// The path is the authority on which application is decided: a body naming a
	// different one would make the URL lie to the audit trail.
	req.ApplicationID = id
	s.applyVerificationPrincipal(r, &req.CommandMeta)
	result, err := s.svc.ClaimVerification(r.Context(), req)
	writeVerificationCommandResult(w, result, err)
}

func (s *Server) handleApproveVerification(w http.ResponseWriter, r *http.Request) {
	id, ok := moderationPathID(w, r, "id")
	if !ok {
		return
	}
	var req admin.ApproveVerificationRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	req.ApplicationID = id
	s.applyVerificationPrincipal(r, &req.CommandMeta)
	result, err := s.svc.ApproveVerification(r.Context(), req)
	writeVerificationCommandResult(w, result, err)
}

func (s *Server) handleRejectVerification(w http.ResponseWriter, r *http.Request) {
	id, ok := moderationPathID(w, r, "id")
	if !ok {
		return
	}
	var req admin.RejectVerificationRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	req.ApplicationID = id
	s.applyVerificationPrincipal(r, &req.CommandMeta)
	result, err := s.svc.RejectVerification(r.Context(), req)
	writeVerificationCommandResult(w, result, err)
}

func (s *Server) handleRevokeVerification(w http.ResponseWriter, r *http.Request) {
	var req admin.RevokeVerificationRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	s.applyVerificationPrincipal(r, &req.CommandMeta)
	result, err := s.svc.RevokeVerification(r.Context(), req)
	writeVerificationCommandResult(w, result, err)
}

// applyVerificationPrincipal fills in the audit actor for a scoped token that did
// not state one.
//
// A scoped token's configured name *is* its audit identity, so an integration
// does not have to invent an actor string. The master token has no name, so a
// caller using it keeps having to state who is acting -- which is what the panel
// does with the signed-in operator.
func (s *Server) applyVerificationPrincipal(r *http.Request, meta *admin.CommandMeta) {
	if strings.TrimSpace(meta.Actor) != "" {
		return
	}
	if name := principalName(r.Context()); name != "" {
		meta.Actor = name
	}
}

// verificationApplicationResponse renders one application. Every int64 crosses
// the JSON boundary as a decimal string: application ids, peer ids and the
// optimistic-locking version exceed the range a JSON number holds exactly, and a
// rounded id would decide the wrong application.
func verificationApplicationResponse(app domain.VerificationApplication) map[string]any {
	out := map[string]any{
		"id":                strconv.FormatInt(app.ID, 10),
		"applicant_user_id": strconv.FormatInt(app.ApplicantUserID, 10),
		"target_type":       string(app.TargetType),
		"target_id":         strconv.FormatInt(app.TargetID, 10),
		"target_title":      app.TargetTitle,
		"target_username":   app.TargetUsername,
		"category":          app.Category,
		"description":       app.Description,
		"official_website":  app.OfficialWebsite,
		"social_links":      stringList(app.SocialLinks),
		"press_links":       stringList(app.PressLinks),
		"additional_note":   app.AdditionalNote,
		"status":            string(app.Status),
		"reviewer_admin_id": app.ReviewerAdminID,
		"decision_reason":   app.DecisionReason,
		// internal_note is operator-only. It is exposed here because every caller
		// of this route already holds verification.review, and it is the reviewer's
		// own handover note; it is never part of the applicant-facing projection.
		"internal_note":  app.InternalNote,
		"correlation_id": app.CorrelationID,
		"version":        strconv.FormatInt(app.Version, 10),
	}
	if !app.CreatedAt.IsZero() {
		out["created_at"] = app.CreatedAt.UTC().Format(time.RFC3339)
	}
	if !app.UpdatedAt.IsZero() {
		out["updated_at"] = app.UpdatedAt.UTC().Format(time.RFC3339)
	}
	if !app.SubmittedAt.IsZero() {
		out["submitted_at"] = app.SubmittedAt.UTC().Format(time.RFC3339)
	}
	if !app.ReviewedAt.IsZero() {
		out["reviewed_at"] = app.ReviewedAt.UTC().Format(time.RFC3339)
	}
	return out
}

func verificationEventResponse(event domain.VerificationApplicationEvent) map[string]any {
	out := map[string]any{
		"id":             strconv.FormatInt(event.ID, 10),
		"application_id": strconv.FormatInt(event.ApplicationID, 10),
		"kind":           string(event.Kind),
		"from_status":    string(event.FromStatus),
		"to_status":      string(event.ToStatus),
		"actor":          event.Actor,
		"reason":         event.Reason,
		"note":           event.Note,
		"correlation_id": event.CorrelationID,
	}
	if !event.CreatedAt.IsZero() {
		out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
	}
	return out
}

func verificationTargetResponse(target domain.VerificationTarget) map[string]any {
	return map[string]any{
		"type":     string(target.Type),
		"id":       strconv.FormatInt(target.ID, 10),
		"title":    target.Title,
		"username": target.Username,
		"verified": target.Verified,
		"eligible": target.Eligible,
		"reason":   target.Reason,
	}
}

// verificationCountsResponse renders the queue summary with every modelled status
// present, so the panel never has to distinguish "zero" from "absent". The values
// are decimal strings for the same exactness reason as the ids.
func verificationCountsResponse(counts domain.VerificationStatusCounts) map[string]string {
	out := make(map[string]string, len(verificationStatusOrder))
	for _, status := range verificationStatusOrder {
		out[string(status)] = strconv.FormatInt(counts[status], 10)
	}
	for status, count := range counts {
		if _, ok := out[string(status)]; !ok {
			out[string(status)] = strconv.FormatInt(count, 10)
		}
	}
	return out
}

// verificationStatusOrder is the closed status set, in lifecycle order.
var verificationStatusOrder = []domain.VerificationStatus{
	domain.VerificationStatusDraft,
	domain.VerificationStatusSubmitted,
	domain.VerificationStatusInReview,
	domain.VerificationStatusApproved,
	domain.VerificationStatusRejected,
	domain.VerificationStatusCancelled,
}

// stringList normalises a nil slice to an empty JSON array, so the panel can
// iterate without a null check.
func stringList(items []string) []string {
	if items == nil {
		return []string{}
	}
	return items
}

// verificationErrorStatus maps a verification failure onto its HTTP status.
//
// The version conflict is the interesting one: it is 409, not 400, because
// nothing about the request was wrong -- another reviewer simply decided first,
// and the panel has to answer that by reloading rather than by correcting input.
func verificationErrorStatus(code string) int {
	switch code {
	case admin.CodeVerificationNotFound:
		return http.StatusNotFound
	case admin.CodeVerificationConflict,
		admin.CodeVerificationTargetOccupied,
		admin.CodeVerificationTargetVerified:
		return http.StatusConflict
	case admin.CodeVerificationStatusInvalid,
		admin.CodeVerificationReasonRequired,
		admin.CodeVerificationTargetInvalid,
		admin.CodeVerificationTargetNotPublic,
		admin.CodeVerificationTargetRestricted,
		admin.CodeVerificationTargetSystem,
		admin.CodeVerificationNotOwner,
		admin.CodeVerificationUserTargetsDisabled,
		admin.CodeVerificationInvalid:
		return http.StatusBadRequest
	default:
		return http.StatusInternalServerError
	}
}

func writeVerificationError(w http.ResponseWriter, err error) {
	code := admin.VerificationErrorCode(err)
	writeCodedError(w, verificationErrorStatus(code), code, err.Error())
}

// writeVerificationCommandResult answers a decision.
//
// The body stays a CommandResult so the panel parses one shape for every
// operator action, but the status is derived from the failure: a lost
// optimistic-locking race must reach the browser as 409, because that is the one
// failure the panel resolves by reloading the application instead of by asking
// the operator to fix the form.
func writeVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
	if err == nil {
		writeJSON(w, http.StatusOK, result)
		return
	}
	code := admin.VerificationErrorCode(err)
	status := verificationErrorStatus(code)
	if status == http.StatusInternalServerError {
		// An unmapped command failure is a bad request, as everywhere else in this
		// API, rather than a server fault.
		status = http.StatusBadRequest
	}
	if result.CommandID == "" {
		result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error(), Code: code}
	}
	if result.Error == "" {
		result.Error = err.Error()
	}
	if result.Code == "" {
		result.Code = code
	}
	if code == admin.CodeVerificationConflict {
		result.Message = "another reviewer changed this application first; reload it and decide again"
	}
	writeJSON(w, status, result)
}