Files
Crussell/backend/handlers/admin/discount_campaigns.go
T

742 lines
21 KiB
Go

package admin
import (
"crussell/db"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"log"
"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.DB.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.DB.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.Header().Set("Content-Type", "application/json")
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 {
http.Error(w, err.Error(), 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
}
// 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 := db.DB.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
}
// 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.Header().Set("Content-Type", "application/json")
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.DB.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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Build dynamic update query
query := "UPDATE discount_campaigns SET updated_at = NOW()"
args := []interface{}{}
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)
_, err = db.DB.Exec(r.Context(), query, args...)
if err != nil {
http.Error(w, "Failed to update campaign: "+err.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.DB.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.Header().Set("Content-Type", "application/json")
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.DB.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"
result, err := db.DB.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
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Campaign deleted successfully",
"id": campaignID,
})
}
// 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.DB.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 == sql.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.DB.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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(stats); err != nil {
log.Printf("Error encoding stats: %v", err)
}
}