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
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>
286 lines
8.3 KiB
Go
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,
|
|
})
|
|
}
|