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"}) }