package admin import ( "crussell/db" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "log" "log/slog" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) // DiscountCampaign represents a discount campaign in the system type DiscountCampaign struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` CampaignType string `json:"campaign_type"` DiscountPercent float64 `json:"discount_percent"` Scope *string `json:"scope,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` EndDate *time.Time `json:"end_date,omitempty"` MilestoneType *string `json:"milestone_type,omitempty"` MilestoneValue *int `json:"milestone_value,omitempty"` MilestoneUnit *string `json:"milestone_unit,omitempty"` Status string `json:"status"` MaxRedemptions *int `json:"max_redemptions,omitempty"` TimesRedeemed int `json:"times_redeemed"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CreatedBy *string `json:"created_by,omitempty"` } // CreateCampaignRequest represents the request payload for creating a new campaign type CreateCampaignRequest struct { Name string `json:"name" validate:"required,min=1,max=200"` Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` CampaignType string `json:"campaign_type"` // "time_based" or "milestone" DiscountPercent float64 `json:"discount_percent"` Scope *string `json:"scope,omitempty"` StartDate *string `json:"start_date,omitempty"` // ISO 8601 EndDate *string `json:"end_date,omitempty"` // ISO 8601 MilestoneType *string `json:"milestone_type,omitempty"` MilestoneValue *int `json:"milestone_value,omitempty"` MilestoneUnit *string `json:"milestone_unit,omitempty"` MaxRedemptions *int `json:"max_redemptions,omitempty"` } // UpdateCampaignRequest represents the request payload for updating a campaign type UpdateCampaignRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=200"` Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` DiscountPercent *float64 `json:"discount_percent,omitempty"` Scope *string `json:"scope,omitempty"` StartDate *string `json:"start_date,omitempty"` EndDate *string `json:"end_date,omitempty"` MilestoneType *string `json:"milestone_type,omitempty"` MilestoneValue *int `json:"milestone_value,omitempty"` MilestoneUnit *string `json:"milestone_unit,omitempty"` Status *string `json:"status,omitempty"` MaxRedemptions *int `json:"max_redemptions,omitempty"` } // CampaignStats represents statistics for a campaign type CampaignStats struct { Campaign DiscountCampaign `json:"campaign"` TotalDiscounts float64 `json:"total_discount_amount"` BookingCount int `json:"booking_count"` } // GetDiscountCampaigns handles GET /api/admin/discount-campaigns // Optional query param ?status=active to filter by status func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) { statusFilter := r.URL.Query().Get("status") if statusFilter != "" { validStatuses := map[string]bool{"draft": true, "active": true, "completed": true, "cancelled": true} if !validStatuses[statusFilter] { http.Error(w, "invalid status filter", http.StatusBadRequest) return } } var query string var rows pgx.Rows var err error if statusFilter != "" { query = ` SELECT id, name, description, campaign_type, discount_percent, scope, start_date, end_date, milestone_type, milestone_value, milestone_unit, status, max_redemptions, times_redeemed, created_at, updated_at, created_by FROM discount_campaigns WHERE status = $1 ORDER BY created_at DESC ` rows, err = db.Conn.Query(r.Context(), query, statusFilter) } else { query = ` SELECT id, name, description, campaign_type, discount_percent, scope, start_date, end_date, milestone_type, milestone_value, milestone_unit, status, max_redemptions, times_redeemed, created_at, updated_at, created_by FROM discount_campaigns ORDER BY created_at DESC ` rows, err = db.Conn.Query(r.Context(), query) } if err != nil { http.Error(w, "Failed to fetch campaigns: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() var campaigns []DiscountCampaign for rows.Next() { var campaign DiscountCampaign var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString var startDate, endDate sql.NullTime var milestoneValue, maxRedemptions sql.NullInt32 var createdBy sql.NullString err := rows.Scan( &campaign.ID, &campaign.Name, &description, &campaignType, &campaign.DiscountPercent, &scope, &startDate, &endDate, &milestoneType, &milestoneValue, &milestoneUnit, &status, &maxRedemptions, &campaign.TimesRedeemed, &campaign.CreatedAt, &campaign.UpdatedAt, &createdBy, ) if err != nil { http.Error(w, "Failed to read campaign data: "+err.Error(), http.StatusInternalServerError) return } // Convert nullable fields if description.Valid { campaign.Description = &description.String } if campaignType.Valid { campaign.CampaignType = campaignType.String } if scope.Valid { campaign.Scope = &scope.String } if startDate.Valid { campaign.StartDate = &startDate.Time } if endDate.Valid { campaign.EndDate = &endDate.Time } if milestoneType.Valid { campaign.MilestoneType = &milestoneType.String } if milestoneValue.Valid { val := int(milestoneValue.Int32) campaign.MilestoneValue = &val } if milestoneUnit.Valid { campaign.MilestoneUnit = &milestoneUnit.String } if status.Valid { campaign.Status = status.String } if maxRedemptions.Valid { val := int(maxRedemptions.Int32) campaign.MaxRedemptions = &val } if createdBy.Valid { campaign.CreatedBy = &createdBy.String } campaigns = append(campaigns, campaign) } if err = rows.Err(); err != nil { http.Error(w, "Error iterating over campaigns: "+err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if campaigns == nil { campaigns = []DiscountCampaign{} } if err := json.NewEncoder(w).Encode(campaigns); err != nil { log.Printf("Error encoding campaigns: %v", err) } } // CreateDiscountCampaign handles POST /api/admin/discount-campaigns func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) { var req CreateCampaignRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Validate required fields if req.Name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } if req.DiscountPercent <= 0 || req.DiscountPercent > 100 { http.Error(w, "Discount percent must be greater than 0 and less than or equal to 100", http.StatusBadRequest) return } if req.CampaignType != "time_based" && req.CampaignType != "milestone" { http.Error(w, "Campaign type must be 'time_based' or 'milestone'", http.StatusBadRequest) return } // Validate time_based requirements if req.CampaignType == "time_based" { if req.StartDate == nil || req.EndDate == nil { http.Error(w, "Start date and end date are required for time_based campaigns", http.StatusBadRequest) return } startTime, err := time.Parse(time.RFC3339, *req.StartDate) if err != nil { http.Error(w, "Invalid start date format. Use ISO 8601 format", http.StatusBadRequest) return } endTime, err := time.Parse(time.RFC3339, *req.EndDate) if err != nil { http.Error(w, "Invalid end date format. Use ISO 8601 format", http.StatusBadRequest) return } if !endTime.After(startTime) { http.Error(w, "End date must be after start date", http.StatusBadRequest) return } } // Validate milestone requirements if req.CampaignType == "milestone" { if req.MilestoneType == nil || req.MilestoneValue == nil || req.MilestoneUnit == nil { http.Error(w, "Milestone type, value, and unit are required for milestone campaigns", http.StatusBadRequest) return } if *req.MilestoneValue <= 0 { http.Error(w, "Milestone value must be greater than 0", http.StatusBadRequest) return } } // Get creator ID from context var createdBy *string if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok { createdBy = &userID } tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() // Insert new campaign query := ` INSERT INTO discount_campaigns ( name, description, campaign_type, discount_percent, scope, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, status, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, name, description, campaign_type, discount_percent, scope, start_date, end_date, milestone_type, milestone_value, milestone_unit, status, max_redemptions, times_redeemed, created_at, updated_at, created_by ` var campaign DiscountCampaign var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString var startDate, endDate sql.NullTime var milestoneValue, maxRedemptions sql.NullInt32 var createdByDB sql.NullString err = tx.QueryRow(r.Context(), query, req.Name, req.Description, req.CampaignType, req.DiscountPercent, req.Scope, req.StartDate, req.EndDate, req.MilestoneType, req.MilestoneValue, req.MilestoneUnit, req.MaxRedemptions, "draft", createdBy, ).Scan( &campaign.ID, &campaign.Name, &description, &campaignType, &campaign.DiscountPercent, &scope, &startDate, &endDate, &milestoneType, &milestoneValue, &milestoneUnit, &status, &maxRedemptions, &campaign.TimesRedeemed, &campaign.CreatedAt, &campaign.UpdatedAt, &createdByDB, ) if err != nil { http.Error(w, "Failed to create campaign: "+err.Error(), http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Convert nullable fields if description.Valid { campaign.Description = &description.String } if campaignType.Valid { campaign.CampaignType = campaignType.String } if scope.Valid { campaign.Scope = &scope.String } if startDate.Valid { campaign.StartDate = &startDate.Time } if endDate.Valid { campaign.EndDate = &endDate.Time } if milestoneType.Valid { campaign.MilestoneType = &milestoneType.String } if milestoneValue.Valid { val := int(milestoneValue.Int32) campaign.MilestoneValue = &val } if milestoneUnit.Valid { campaign.MilestoneUnit = &milestoneUnit.String } if status.Valid { campaign.Status = status.String } if maxRedemptions.Valid { val := int(maxRedemptions.Int32) campaign.MaxRedemptions = &val } if createdByDB.Valid { campaign.CreatedBy = &createdByDB.String } w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(campaign); err != nil { log.Printf("Error encoding campaign: %v", err) } } // UpdateDiscountCampaign handles PUT /api/admin/discount-campaigns/{id} func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) { campaignID := chi.URLParam(r, "id") if campaignID == "" || !validators.IsValidID(campaignID) { http.Error(w, "Campaign not found", http.StatusNotFound) return } // Check if campaign exists var exists bool err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists) if err != nil { http.Error(w, "Failed to check campaign: "+err.Error(), http.StatusInternalServerError) return } if !exists { http.Error(w, "Campaign not found", http.StatusNotFound) return } var req UpdateCampaignRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Build dynamic update query query := "UPDATE discount_campaigns SET updated_at = NOW()" args := []any{} argNum := 1 if req.Name != nil { query += ", name = $" + strconv.Itoa(argNum) args = append(args, *req.Name) argNum++ } if req.Description != nil { query += ", description = $" + strconv.Itoa(argNum) args = append(args, *req.Description) argNum++ } if req.DiscountPercent != nil { if *req.DiscountPercent <= 0 || *req.DiscountPercent > 100 { http.Error(w, "Discount percent must be greater than 0 and less than or equal to 100", http.StatusBadRequest) return } query += ", discount_percent = $" + strconv.Itoa(argNum) args = append(args, *req.DiscountPercent) argNum++ } if req.Scope != nil { query += ", scope = $" + strconv.Itoa(argNum) args = append(args, *req.Scope) argNum++ } if req.StartDate != nil { startTime, err := time.Parse(time.RFC3339, *req.StartDate) if err != nil { http.Error(w, "Invalid start date format. Use ISO 8601 format", http.StatusBadRequest) return } query += ", start_date = $" + strconv.Itoa(argNum) args = append(args, startTime) argNum++ } if req.EndDate != nil { endTime, err := time.Parse(time.RFC3339, *req.EndDate) if err != nil { http.Error(w, "Invalid end date format. Use ISO 8601 format", http.StatusBadRequest) return } query += ", end_date = $" + strconv.Itoa(argNum) args = append(args, endTime) argNum++ } if req.MilestoneType != nil { query += ", milestone_type = $" + strconv.Itoa(argNum) args = append(args, *req.MilestoneType) argNum++ } if req.MilestoneValue != nil { if *req.MilestoneValue <= 0 { http.Error(w, "Milestone value must be greater than 0", http.StatusBadRequest) return } query += ", milestone_value = $" + strconv.Itoa(argNum) args = append(args, *req.MilestoneValue) argNum++ } if req.MilestoneUnit != nil { query += ", milestone_unit = $" + strconv.Itoa(argNum) args = append(args, *req.MilestoneUnit) argNum++ } if req.Status != nil { if *req.Status != "draft" && *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" { http.Error(w, "Status must be 'draft', 'active', 'cancelled', or 'completed'", http.StatusBadRequest) return } query += ", status = $" + strconv.Itoa(argNum) args = append(args, *req.Status) argNum++ } if req.MaxRedemptions != nil { query += ", max_redemptions = $" + strconv.Itoa(argNum) args = append(args, *req.MaxRedemptions) argNum++ } query += " WHERE id = $" + strconv.Itoa(argNum) args = append(args, campaignID) tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() _, err = tx.Exec(r.Context(), query, args...) if err != nil { http.Error(w, "Failed to update campaign: "+err.Error(), http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Fetch updated campaign var campaign DiscountCampaign var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString var startDate, endDate sql.NullTime var milestoneValue, maxRedemptions sql.NullInt32 var createdBy sql.NullString err = db.Conn.QueryRow(r.Context(), ` SELECT id, name, description, campaign_type, discount_percent, scope, start_date, end_date, milestone_type, milestone_value, milestone_unit, status, max_redemptions, times_redeemed, created_at, updated_at, created_by FROM discount_campaigns WHERE id = $1 `, campaignID).Scan( &campaign.ID, &campaign.Name, &description, &campaignType, &campaign.DiscountPercent, &scope, &startDate, &endDate, &milestoneType, &milestoneValue, &milestoneUnit, &status, &maxRedemptions, &campaign.TimesRedeemed, &campaign.CreatedAt, &campaign.UpdatedAt, &createdBy, ) if err != nil { http.Error(w, "Failed to fetch updated campaign: "+err.Error(), http.StatusInternalServerError) return } // Convert nullable fields if description.Valid { campaign.Description = &description.String } if campaignType.Valid { campaign.CampaignType = campaignType.String } if scope.Valid { campaign.Scope = &scope.String } if startDate.Valid { campaign.StartDate = &startDate.Time } if endDate.Valid { campaign.EndDate = &endDate.Time } if milestoneType.Valid { campaign.MilestoneType = &milestoneType.String } if milestoneValue.Valid { val := int(milestoneValue.Int32) campaign.MilestoneValue = &val } if milestoneUnit.Valid { campaign.MilestoneUnit = &milestoneUnit.String } if status.Valid { campaign.Status = status.String } if maxRedemptions.Valid { val := int(maxRedemptions.Int32) campaign.MaxRedemptions = &val } if createdBy.Valid { campaign.CreatedBy = &createdBy.String } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(campaign); err != nil { log.Printf("Error encoding campaign: %v", err) } } // DeleteDiscountCampaign handles DELETE /api/admin/discount-campaigns/{id} // Soft delete - sets status to "cancelled" func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) { campaignID := chi.URLParam(r, "id") if campaignID == "" || !validators.IsValidID(campaignID) { http.Error(w, "Campaign not found", http.StatusNotFound) return } // Check if campaign exists var exists bool err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists) if err != nil { http.Error(w, "Failed to check campaign: "+err.Error(), http.StatusInternalServerError) return } if !exists { http.Error(w, "Campaign not found", http.StatusNotFound) return } // Soft delete - set status to cancelled query := "UPDATE discount_campaigns SET status = 'cancelled', updated_at = NOW() WHERE id = $1" tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() result, err := tx.Exec(r.Context(), query, campaignID) if err != nil { http.Error(w, "Failed to delete campaign: "+err.Error(), http.StatusInternalServerError) return } if result.RowsAffected() == 0 { http.Error(w, "Campaign not found", http.StatusNotFound) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "message": "Campaign deleted successfully", "id": campaignID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // GetCampaignStats handles GET /api/admin/discount-campaigns/{id}/stats func GetCampaignStats(w http.ResponseWriter, r *http.Request) { campaignID := chi.URLParam(r, "id") if campaignID == "" || !validators.IsValidID(campaignID) { http.Error(w, "Campaign not found", http.StatusNotFound) return } // Fetch campaign details var campaign DiscountCampaign var description, scope, campaignType, status, milestoneType, milestoneUnit sql.NullString var startDate, endDate sql.NullTime var milestoneValue, maxRedemptions sql.NullInt32 var createdBy sql.NullString err := db.Conn.QueryRow(r.Context(), ` SELECT id, name, description, campaign_type, discount_percent, scope, start_date, end_date, milestone_type, milestone_value, milestone_unit, status, max_redemptions, times_redeemed, created_at, updated_at, created_by FROM discount_campaigns WHERE id = $1 `, campaignID).Scan( &campaign.ID, &campaign.Name, &description, &campaignType, &campaign.DiscountPercent, &scope, &startDate, &endDate, &milestoneType, &milestoneValue, &milestoneUnit, &status, &maxRedemptions, &campaign.TimesRedeemed, &campaign.CreatedAt, &campaign.UpdatedAt, &createdBy, ) if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Campaign not found", http.StatusNotFound) return } if err != nil { http.Error(w, "Failed to fetch campaign: "+err.Error(), http.StatusInternalServerError) return } // Convert nullable fields if description.Valid { campaign.Description = &description.String } if campaignType.Valid { campaign.CampaignType = campaignType.String } if scope.Valid { campaign.Scope = &scope.String } if startDate.Valid { campaign.StartDate = &startDate.Time } if endDate.Valid { campaign.EndDate = &endDate.Time } if milestoneType.Valid { campaign.MilestoneType = &milestoneType.String } if milestoneValue.Valid { val := int(milestoneValue.Int32) campaign.MilestoneValue = &val } if milestoneUnit.Valid { campaign.MilestoneUnit = &milestoneUnit.String } if status.Valid { campaign.Status = status.String } if maxRedemptions.Valid { val := int(maxRedemptions.Int32) campaign.MaxRedemptions = &val } if createdBy.Valid { campaign.CreatedBy = &createdBy.String } // Get stats from booking_discounts table var totalDiscounts float64 var bookingCount int err = db.Conn.QueryRow(r.Context(), ` SELECT COALESCE(SUM(discount_amount), 0), COUNT(*) FROM booking_discounts WHERE source_id = $1 `, campaignID).Scan(&totalDiscounts, &bookingCount) if err != nil { http.Error(w, "Failed to fetch campaign stats: "+err.Error(), http.StatusInternalServerError) return } stats := CampaignStats{ Campaign: campaign, TotalDiscounts: totalDiscounts, BookingCount: bookingCount, } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(stats); err != nil { log.Printf("Error encoding stats: %v", err) } }