- Database: loyalty_redemptions, discount_campaigns, booking_discounts tables - Backend: auto-create pending redemption at 10 stamps, apply discounts at completion - Backend: discount_eligible flag on booking creation (user + admin flows) - Backend: campaign CRUD handlers (GET/POST/PUT/DELETE + stats) - Backend: milestone campaigns (per-user, global, anniversary) - Frontend: customer account page shows 'card full' status at 10 stamps - Frontend: admin discounts page with campaign management UI - Frontend: TypeScript types for all discount entities - Tests: 9 integration tests covering loyalty, campaigns, milestones, edge cases
723 lines
20 KiB
Go
723 lines
20 KiB
Go
package admin
|
|
|
|
import (
|
|
"crussell/db"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"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"`
|
|
Description *string `json:"description,omitempty"`
|
|
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"`
|
|
Description *string `json:"description,omitempty"`
|
|
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")
|
|
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
"active",
|
|
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
|
|
}
|
|
|
|
// Build dynamic update query
|
|
query := "UPDATE discount_campaigns SET updated_at = NOW()"
|
|
args := []interface{}{}
|
|
argNum := 1
|
|
|
|
if req.Name != nil {
|
|
query += ", name = $" + string(rune('0'+argNum))
|
|
args = append(args, *req.Name)
|
|
argNum++
|
|
}
|
|
if req.Description != nil {
|
|
query += ", description = $" + string(rune('0'+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 = $" + string(rune('0'+argNum))
|
|
args = append(args, *req.DiscountPercent)
|
|
argNum++
|
|
}
|
|
if req.Scope != nil {
|
|
query += ", scope = $" + string(rune('0'+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 = $" + string(rune('0'+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 = $" + string(rune('0'+argNum))
|
|
args = append(args, endTime)
|
|
argNum++
|
|
}
|
|
if req.MilestoneType != nil {
|
|
query += ", milestone_type = $" + string(rune('0'+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 = $" + string(rune('0'+argNum))
|
|
args = append(args, *req.MilestoneValue)
|
|
argNum++
|
|
}
|
|
if req.MilestoneUnit != nil {
|
|
query += ", milestone_unit = $" + string(rune('0'+argNum))
|
|
args = append(args, *req.MilestoneUnit)
|
|
argNum++
|
|
}
|
|
if req.Status != nil {
|
|
if *req.Status != "active" && *req.Status != "cancelled" && *req.Status != "completed" {
|
|
http.Error(w, "Status must be 'active', 'cancelled', or 'completed'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
query += ", status = $" + string(rune('0'+argNum))
|
|
args = append(args, *req.Status)
|
|
argNum++
|
|
}
|
|
if req.MaxRedemptions != nil {
|
|
query += ", max_redemptions = $" + string(rune('0'+argNum))
|
|
args = append(args, *req.MaxRedemptions)
|
|
argNum++
|
|
}
|
|
|
|
query += " WHERE id = $" + string(rune('0'+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)
|
|
}
|
|
} |