Files
Crussell/backend/handlers/admin/discount_campaigns.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

783 lines
22 KiB
Go

package admin
import (
"crussell/db"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"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.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 {
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
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// 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 {
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)
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 tx.Rollback(r.Context())
_, 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 tx.Rollback(r.Context())
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)
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.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)
}
}