feat: loyalty/discount system with milestone campaigns, auto-redemption, and admin UI
- 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
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user