veza/veza-backend-api/internal/handlers/distribution_handler.go
senke 3cee007d8d
Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s
fix(distribution,audit): propagate ErrSubscriptionNoPayment to handler + P0.12 closure date + E2E regression TODO
Self-review of the v1.0.6.2 hotfix surfaced that
distribution.checkEligibility silently swallowed
subscription.ErrSubscriptionNoPayment as "ineligible, no extra info",
so a user with a fantôme subscription trying to submit a distribution
got "Distribution requires Creator or Premium plan" — misleading, the
user has a plan but no payment. checkEligibility now propagates the
error so the handler can surface "Your subscription is not linked to
a payment. Complete payment to enable distribution."

Security is unchanged — the gate still refuses. This is a UX clarity
fix for honest-path users who landed in the fantôme state via a
broken payment flow.

Also:
- Closure timestamp added to axis-1 P0.12 ("closed 2026-04-17 in
  v1.0.6.2 (commit d31f5733d)") so future readers know the finding's
  lifecycle without re-grepping the CHANGELOG.
- Item G in v107-plan.md gains an explicit E2E Playwright @critical
  acceptance — the shell probe + Go unit tests validate the fix
  today but don't run on every commit, so a refactor of Subscribe or
  checkEligibility could silently re-open the bypass. The E2E test
  makes regression coverage automatic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:43:21 +02:00

286 lines
8.3 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"time"
"veza-backend-api/internal/core/distribution"
"veza-backend-api/internal/core/subscription"
apperrors "veza-backend-api/internal/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// DistributionHandler handles distribution HTTP endpoints (v0.12.2 F501-F510)
type DistributionHandler struct {
service *distribution.Service
logger *zap.Logger
}
// NewDistributionHandler creates a new DistributionHandler
func NewDistributionHandler(service *distribution.Service, logger *zap.Logger) *DistributionHandler {
return &DistributionHandler{
service: service,
logger: logger,
}
}
// Submit handles POST /distributions/submit
func (h *DistributionHandler) Submit(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req distribution.SubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: track_id, platforms, and metadata required"))
return
}
resp, err := h.service.Submit(c.Request.Context(), userID, req)
if err != nil {
switch {
case errors.Is(err, distribution.ErrNotEligible):
RespondWithAppError(c, apperrors.NewForbiddenError("Distribution requires Creator or Premium plan"))
case errors.Is(err, subscription.ErrSubscriptionNoPayment):
// v1.0.6.2: the user has a plan but no effective payment
// linkage. Distinct from ErrNotEligible so the UX can tell
// them to complete payment rather than upgrade.
RespondWithAppError(c, apperrors.NewForbiddenError("Your subscription is not linked to a payment. Complete payment to enable distribution."))
case errors.Is(err, distribution.ErrTrackNotPublic):
RespondWithAppError(c, apperrors.NewValidationError("Track must be public and belong to you"))
case errors.Is(err, distribution.ErrAlreadyDistributed):
RespondWithAppError(c, apperrors.NewValidationError("Track already has an active distribution"))
case errors.Is(err, distribution.ErrNoPlatformsSelected):
RespondWithAppError(c, apperrors.NewValidationError("At least one platform must be selected"))
case errors.Is(err, distribution.ErrInvalidPlatform):
RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to submit distribution", err))
}
return
}
RespondSuccess(c, http.StatusCreated, resp)
}
// GetDistribution handles GET /distributions/:id
func (h *DistributionHandler) GetDistribution(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
distID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid distribution ID"))
return
}
dist, err := h.service.GetDistribution(c.Request.Context(), distID)
if err != nil {
if errors.Is(err, distribution.ErrDistributionNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get distribution", err))
return
}
// Ensure the distribution belongs to the requesting user
if dist.CreatorID != userID {
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
return
}
RespondSuccess(c, http.StatusOK, dist)
}
// ListDistributions handles GET /distributions
func (h *DistributionHandler) ListDistributions(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
var trackID *uuid.UUID
if tidStr := c.Query("track_id"); tidStr != "" {
tid, err := uuid.Parse(tidStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid track_id"))
return
}
trackID = &tid
}
var status *distribution.DistributionStatus
if statusStr := c.Query("status"); statusStr != "" {
s := distribution.DistributionStatus(statusStr)
status = &s
}
dists, total, err := h.service.ListDistributions(c.Request.Context(), userID, trackID, status, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list distributions", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": dists,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// GetTrackDistributions handles GET /tracks/:track_id/distributions
func (h *DistributionHandler) GetTrackDistributions(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
trackID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid track ID"))
return
}
dists, err := h.service.GetTrackDistributions(c.Request.Context(), trackID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get track distributions", err))
return
}
// Filter to only show creator's own distributions
var filtered []distribution.TrackDistribution
for _, d := range dists {
if d.CreatorID == userID {
filtered = append(filtered, d)
}
}
RespondSuccess(c, http.StatusOK, gin.H{"distributions": filtered})
}
// GetStatusHistory handles GET /distributions/:id/status-history
func (h *DistributionHandler) GetStatusHistory(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
distID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid distribution ID"))
return
}
// Verify ownership
dist, err := h.service.GetDistribution(c.Request.Context(), distID)
if err != nil {
if errors.Is(err, distribution.ErrDistributionNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get distribution", err))
return
}
if dist.CreatorID != userID {
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
return
}
entries, err := h.service.GetStatusHistory(c.Request.Context(), distID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get status history", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"history": entries})
}
// RemoveDistribution handles POST /distributions/:id/remove
func (h *DistributionHandler) RemoveDistribution(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
distID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid distribution ID"))
return
}
if err := h.service.RemoveDistribution(c.Request.Context(), userID, distID); err != nil {
switch {
case errors.Is(err, distribution.ErrDistributionNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Distribution"))
case errors.Is(err, distribution.ErrDistributionNotRemovable):
RespondWithAppError(c, apperrors.NewValidationError("Distribution can only be removed when live"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to remove distribution", err))
}
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Distribution removal requested"})
}
// GetExternalRoyalties handles GET /creators/me/external-royalties
func (h *DistributionHandler) GetExternalRoyalties(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
var startDate, endDate *time.Time
if sd := c.Query("start_date"); sd != "" {
t, err := time.Parse("2006-01-02", sd)
if err == nil {
startDate = &t
}
}
if ed := c.Query("end_date"); ed != "" {
t, err := time.Parse("2006-01-02", ed)
if err == nil {
endDate = &t
}
}
var platform *string
if p := c.Query("platform"); p != "" {
platform = &p
}
royalties, summary, err := h.service.GetExternalRoyalties(c.Request.Context(), userID, startDate, endDate, platform, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get royalties", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"data": royalties,
"summary": summary,
})
}