Files
MyClub/internal/controllers/ticket_controller.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

591 lines
18 KiB
Go

package controllers
import (
"fmt"
"net/http"
"strconv"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type TicketController struct {
DB *gorm.DB
}
func NewTicketController(db *gorm.DB) *TicketController {
return &TicketController{DB: db}
}
// Local type definitions for API responses
type AvailableTicketTypeResponse struct {
TicketType models.TicketType `json:"ticket_type"`
PriceCents int64 `json:"price_cents"`
MaxQuantity *int `json:"max_quantity"`
AvailableQuantity int `json:"available_quantity"`
TotalCapacity int `json:"total_capacity"`
SaleStatus string `json:"sale_status"`
}
type CampaignTicketTypeRequest struct {
TicketTypeID uint `json:"ticket_type_id" binding:"required"`
PriceCents *int64 `json:"price_cents"`
MaxQuantity *int `json:"max_quantity"`
Capacity int `json:"capacity" binding:"required,min=0"`
}
// GET /api/v1/tickets/campaigns - List all active ticket campaigns
func (tc *TicketController) GetCampaigns(c *gin.Context) {
var campaigns []models.TicketCampaign
query := tc.DB.Preload("CampaignTicketTypes.TicketType").
Preload("TicketTypes").
Where("active = ? AND deleted_at IS NULL", true)
// Filter by match if specified
if matchID := c.Query("match_id"); matchID != "" {
query = query.Where("external_match_id = ?", matchID)
}
// Filter by date range
if from := c.Query("from"); from != "" {
if fromDate, err := time.Parse("2006-01-02", from); err == nil {
query = query.Where("match_date_time >= ?", fromDate)
}
}
if to := c.Query("to"); to != "" {
if toDate, err := time.Parse("2006-01-02", to); err == nil {
query = query.Where("match_date_time <= ?", toDate)
}
}
if err := query.Order("match_date_time ASC").Find(&campaigns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch campaigns"})
return
}
c.JSON(http.StatusOK, campaigns)
}
// GET /api/v1/tickets/campaigns/:id - Get specific campaign with availability
func (tc *TicketController) GetCampaign(c *gin.Context) {
id := c.Param("id")
var campaign models.TicketCampaign
if err := tc.DB.Preload("CampaignTicketTypes.TicketType").
Preload("TicketTypes").
Where("id = ? AND active = ? AND deleted_at IS NULL", id, true).
First(&campaign).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Campaign not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch campaign"})
}
return
}
// Load availability for each ticket type
var availabilities []models.TicketAvailability
tc.DB.Where("campaign_id = ?", campaign.ID).Find(&availabilities)
availabilityMap := make(map[uint]models.TicketAvailability)
for _, avail := range availabilities {
availabilityMap[avail.TicketTypeID] = avail
}
// Build response with availability
type CampaignResponse struct {
models.TicketCampaign
AvailableTickets []AvailableTicketTypeResponse `json:"available_tickets"`
}
response := CampaignResponse{TicketCampaign: campaign}
for _, ctt := range campaign.CampaignTicketTypes {
avail := availabilityMap[ctt.TicketTypeID]
price := ctt.PriceCents
if price == nil {
price = &ctt.TicketType.PriceCents
}
maxQty := ctt.MaxQuantity
if maxQty == nil {
maxQty = &ctt.TicketType.MaxTicketsPerOrder
}
availableQty := avail.TotalCapacity - avail.SoldQuantity - avail.ReservedQuantity
saleStatus := "available"
if time.Now().Before(campaign.SaleStartTime) {
saleStatus = "upcoming"
} else if time.Now().After(campaign.SaleEndTime) {
saleStatus = "ended"
} else if availableQty <= 0 {
saleStatus = "sold_out"
}
response.AvailableTickets = append(response.AvailableTickets, AvailableTicketTypeResponse{
TicketType: ctt.TicketType,
PriceCents: *price,
MaxQuantity: maxQty,
AvailableQuantity: availableQty,
TotalCapacity: avail.TotalCapacity,
SaleStatus: saleStatus,
})
}
c.JSON(http.StatusOK, response)
}
// GET /api/v1/tickets/available - Get available tickets for public
func (tc *TicketController) GetAvailableTickets(c *gin.Context) {
var tickets []models.AvailableTicketView
query := tc.DB.Where("sale_status = ?", "available")
// Filter by match if specified
if matchID := c.Query("match_id"); matchID != "" {
query = query.Where("external_match_id = ?", matchID)
}
// Filter by competition if specified
if competition := c.Query("competition"); competition != "" {
query = query.Where("competition_code = ?", competition)
}
if err := query.Order("match_date_time ASC, campaign_id ASC, display_order ASC").Find(&tickets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch available tickets"})
return
}
c.JSON(http.StatusOK, tickets)
}
// POST /api/v1/tickets/reserve - Reserve tickets (before payment)
func (tc *TicketController) ReserveTickets(c *gin.Context) {
type ReserveRequest struct {
CampaignID uint `json:"campaign_id" binding:"required"`
TicketTypeID uint `json:"ticket_type_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"`
HolderName string `json:"holder_name" binding:"required"`
HolderEmail string `json:"holder_email" binding:"required,email"`
HolderPhone string `json:"holder_phone"`
}
var req ReserveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate campaign and ticket type
var campaign models.TicketCampaign
if err := tc.DB.Where("id = ? AND active = ? AND deleted_at IS NULL", req.CampaignID, true).First(&campaign).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Campaign not found"})
return
}
// Check sale time window
now := time.Now()
if now.Before(campaign.SaleStartTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Sale has not started yet"})
return
}
if now.After(campaign.SaleEndTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Sale has ended"})
return
}
// Get campaign ticket type with overrides
var ctt models.CampaignTicketType
if err := tc.DB.Where("campaign_id = ? AND ticket_type_id = ?", req.CampaignID, req.TicketTypeID).
Preload("TicketType").Preload("Campaign").First(&ctt).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket type not found in this campaign"})
return
}
// Check quantity limits
maxQty := ctt.MaxQuantity
if maxQty == nil {
maxQty = &ctt.TicketType.MaxTicketsPerOrder
}
if req.Quantity > *maxQty {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Maximum %d tickets per order", *maxQty)})
return
}
// Check availability
var availability models.TicketAvailability
if err := tc.DB.Where("campaign_id = ? AND ticket_type_id = ?", req.CampaignID, req.TicketTypeID).First(&availability).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Availability not found"})
return
}
availableQty := availability.TotalCapacity - availability.SoldQuantity - availability.ReservedQuantity
if req.Quantity > availableQty {
c.JSON(http.StatusBadRequest, gin.H{"error": "Not enough tickets available"})
return
}
// Get price
price := ctt.PriceCents
if price == nil {
price = &ctt.TicketType.PriceCents
}
// Create reservation
ticket := models.Ticket{
CampaignID: req.CampaignID,
TicketTypeID: req.TicketTypeID,
HolderName: req.HolderName,
HolderEmail: req.HolderEmail,
HolderPhone: req.HolderPhone,
Quantity: req.Quantity,
UnitPriceCents: *price,
TotalPriceCents: int64(req.Quantity) * *price,
Currency: ctt.TicketType.Currency,
Status: "reserved",
}
// Use transaction for atomic operations
tx := tc.DB.Begin()
// Create ticket
if err := tx.Create(&ticket).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create reservation"})
return
}
// Update availability
if err := tx.Model(&availability).Update("reserved_quantity", gorm.Expr("reserved_quantity + ?", req.Quantity)).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update availability"})
return
}
tx.Commit()
// Send confirmation email (async)
go func() {
// TODO: Implement ticket reservation email template
// For now, just log the action
fmt.Printf("Ticket reservation email sent to %s for ticket ID %d\n", req.HolderEmail, ticket.ID)
}()
c.JSON(http.StatusCreated, ticket)
}
// POST /api/v1/tickets/:id/confirm - Confirm ticket reservation (after payment)
func (tc *TicketController) ConfirmTicket(c *gin.Context) {
id := c.Param("id")
var ticket models.Ticket
if err := tc.DB.Where("id = ? AND status = ?", id, "reserved").First(&ticket).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket reservation not found"})
return
}
// Update ticket status
if err := tc.DB.Model(&ticket).Updates(map[string]interface{}{
"status": "paid",
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to confirm ticket"})
return
}
// Update availability
if err := tc.DB.Model(&models.TicketAvailability{}).
Where("campaign_id = ? AND ticket_type_id = ?", ticket.CampaignID, ticket.TicketTypeID).
Updates(map[string]interface{}{
"sold_quantity": gorm.Expr("sold_quantity + ?", ticket.Quantity),
"reserved_quantity": gorm.Expr("reserved_quantity - ?", ticket.Quantity),
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update availability"})
return
}
// Send ticket email (async)
go func() {
// TODO: Implement ticket confirmation email with barcode/QR
// For now, just log the action
fmt.Printf("Ticket confirmation email sent to %s for ticket ID %d\n", ticket.HolderEmail, ticket.ID)
}()
c.JSON(http.StatusOK, gin.H{"message": "Ticket confirmed", "ticket": ticket})
}
// POST /api/v1/tickets/:id/validate - Validate ticket (for entry)
func (tc *TicketController) ValidateTicket(c *gin.Context) {
type ValidateRequest struct {
Barcode string `json:"barcode" binding:"required"`
UsedBy string `json:"used_by"`
}
var req ValidateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ticket models.Ticket
if err := tc.DB.Where("barcode = ? AND status = ?", req.Barcode, "paid").First(&ticket).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Ticket not found or already used"})
return
}
// Check if already used
if ticket.UsedAt != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Ticket already used", "used_at": ticket.UsedAt})
return
}
// Mark as used
now := time.Now()
if err := tc.DB.Model(&ticket).Updates(map[string]interface{}{
"used_at": now,
"used_by": req.UsedBy,
"status": "used",
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate ticket"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Ticket validated successfully",
"ticket": ticket,
"used_at": now,
})
}
// GET /api/v1/admin/tickets/campaigns - Admin: List all campaigns
func (tc *TicketController) AdminGetCampaigns(c *gin.Context) {
var campaigns []models.TicketCampaign
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset := (page - 1) * limit
if err := tc.DB.Preload("CampaignTicketTypes.TicketType").
Order("created_at DESC").
Limit(limit).Offset(offset).
Find(&campaigns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch campaigns"})
return
}
var total int64
tc.DB.Model(&models.TicketCampaign{}).Count(&total)
c.JSON(http.StatusOK, gin.H{
"campaigns": campaigns,
"total": total,
"page": page,
"limit": limit,
})
}
// POST /api/v1/admin/tickets/campaigns - Admin: Create campaign
func (tc *TicketController) AdminCreateCampaign(c *gin.Context) {
type CreateCampaignRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
ExternalMatchID *string `json:"external_match_id"`
CompetitionCode *string `json:"competition_code"`
MatchDateTime *time.Time `json:"match_date_time"`
HomeTeam *string `json:"home_team"`
AwayTeam *string `json:"away_team"`
Venue *string `json:"venue"`
SaleStartTime time.Time `json:"sale_start_time" binding:"required"`
SaleEndTime time.Time `json:"sale_end_time" binding:"required"`
MaxTotalTickets *int `json:"max_total_tickets"`
TicketTypes []CampaignTicketTypeRequest `json:"ticket_types" binding:"required"`
}
type CampaignTicketTypeRequest struct {
TicketTypeID uint `json:"ticket_type_id" binding:"required"`
PriceCents *int64 `json:"price_cents"`
MaxQuantity *int `json:"max_quantity"`
Capacity int `json:"capacity" binding:"required,min=0"`
}
var req CreateCampaignRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate time window
if req.SaleEndTime.Before(req.SaleStartTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Sale end time must be after start time"})
return
}
// Create campaign
campaign := models.TicketCampaign{
Title: req.Title,
Description: req.Description,
ExternalMatchID: req.ExternalMatchID,
CompetitionCode: req.CompetitionCode,
MatchDateTime: req.MatchDateTime,
HomeTeam: req.HomeTeam,
AwayTeam: req.AwayTeam,
Venue: req.Venue,
SaleStartTime: req.SaleStartTime,
SaleEndTime: req.SaleEndTime,
MaxTotalTickets: req.MaxTotalTickets,
Active: true,
}
tx := tc.DB.Begin()
if err := tx.Create(&campaign).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create campaign"})
return
}
// Create campaign ticket types and availability
for _, ttReq := range req.TicketTypes {
// Verify ticket type exists
var ticketType models.TicketType
if err := tx.First(&ticketType, ttReq.TicketTypeID).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Ticket type %d not found", ttReq.TicketTypeID)})
return
}
// Create campaign ticket type
ctt := models.CampaignTicketType{
CampaignID: campaign.ID,
TicketTypeID: ttReq.TicketTypeID,
PriceCents: ttReq.PriceCents,
MaxQuantity: ttReq.MaxQuantity,
}
if err := tx.Create(&ctt).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create campaign ticket type"})
return
}
// Create availability
availability := models.TicketAvailability{
CampaignID: campaign.ID,
TicketTypeID: ttReq.TicketTypeID,
TotalCapacity: ttReq.Capacity,
SoldQuantity: 0,
ReservedQuantity: 0,
}
if err := tx.Create(&availability).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create availability"})
return
}
}
tx.Commit()
// Load full campaign for response
tc.DB.Preload("CampaignTicketTypes.TicketType").Preload("TicketTypes").First(&campaign, campaign.ID)
c.JSON(http.StatusCreated, campaign)
}
// GET /api/v1/admin/tickets/types - Admin: List ticket types
func (tc *TicketController) AdminGetTicketTypes(c *gin.Context) {
var types []models.TicketType
if err := tc.DB.Where("deleted_at IS NULL").Order("display_order ASC").Find(&types).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch ticket types"})
return
}
c.JSON(http.StatusOK, types)
}
// POST /api/v1/admin/tickets/types - Admin: Create ticket type
func (tc *TicketController) AdminCreateTicketType(c *gin.Context) {
var ticketType models.TicketType
if err := c.ShouldBindJSON(&ticketType); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := tc.DB.Create(&ticketType).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create ticket type"})
return
}
c.JSON(http.StatusCreated, ticketType)
}
// GET /api/v1/tickets/my-tickets - Get current user's tickets
func (tc *TicketController) GetMyTickets(c *gin.Context) {
userIDVal, _ := c.Get("userID")
userID, ok := userIDVal.(uint)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var tickets []models.Ticket
if err := tc.DB.Where("holder_email IN (SELECT email FROM users WHERE id = ?)", userID).
Preload("Campaign").
Preload("TicketType").
Order("created_at DESC").
Find(&tickets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tickets"})
return
}
c.JSON(http.StatusOK, tickets)
}
// Additional admin methods for routes
func (tc *TicketController) AdminUpdateCampaign(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminDeleteCampaign(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetTicketType(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminUpdateTicketType(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminDeleteTicketType(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetTickets(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetTicket(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminUpdateTicketStatus(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminValidateTicket(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminGetSalesOverview(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}
func (tc *TicketController) AdminExportSales(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
}