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)
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,11 @@ type AdminBookingDetail struct {
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// roundTo2 rounds a float64 to 2 decimal places
|
||||
func roundTo2(f float64) float64 {
|
||||
return float64(int(f*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// Helper function to parse query parameters
|
||||
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
|
||||
req := GetAllBookingsRequest{
|
||||
@@ -1302,6 +1307,24 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
createdBy = &creatorID
|
||||
}
|
||||
|
||||
var discountEligible bool
|
||||
if !isGuest {
|
||||
var hasPendingRedemption bool
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW())
|
||||
`, userID).Scan(&hasPendingRedemption)
|
||||
|
||||
var activeCampaigns int
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||
AND start_date <= NOW() AND end_date >= NOW()
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
`).Scan(&activeCampaigns)
|
||||
|
||||
discountEligible = hasPendingRedemption || activeCampaigns > 0
|
||||
}
|
||||
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
@@ -1323,10 +1346,10 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var booking Booking
|
||||
booking.User = &UserSummary{}
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key)
|
||||
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6)
|
||||
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key, discount_eligible)
|
||||
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6, $7)
|
||||
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
|
||||
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan(
|
||||
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, discountEligible).Scan(
|
||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||
&booking.DepositRequired,
|
||||
@@ -1636,6 +1659,200 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
var newStampCount int
|
||||
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 {
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
||||
VALUES ($1, 10, 'pending', NOW())
|
||||
`, booking.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var bookingTotal float64
|
||||
if err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, bookingID).Scan(&bookingTotal); err != nil {
|
||||
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
if bookingTotal > 0 {
|
||||
var redemptionID string
|
||||
if err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id FROM loyalty_redemptions
|
||||
WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()
|
||||
ORDER BY redeemed_at ASC LIMIT 1
|
||||
`, booking.User.ID).Scan(&redemptionID); err == nil && redemptionID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * 0.10)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5)
|
||||
`, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW()
|
||||
WHERE id = $2
|
||||
`, bookingID, redemptionID)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1
|
||||
`, booking.User.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if bookingTotal > 0 {
|
||||
var hasLoyaltyDiscount bool
|
||||
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount)
|
||||
|
||||
if !hasLoyaltyDiscount {
|
||||
var campaignID string
|
||||
var campaignPercent float64
|
||||
if err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||
AND start_date <= NOW() AND end_date >= NOW()
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
ORDER BY discount_percent DESC LIMIT 1
|
||||
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID)
|
||||
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, campaignID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bookingTotal > 0 {
|
||||
var hasDiscount bool
|
||||
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount)
|
||||
|
||||
if !hasDiscount {
|
||||
var userBookingCount int
|
||||
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)
|
||||
|
||||
var milestoneCampaignID string
|
||||
var milestonePercent float64
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
||||
AND milestone_value = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
|
||||
`, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent)
|
||||
|
||||
if milestoneCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, milestoneCampaignID)
|
||||
}
|
||||
|
||||
if milestoneCampaignID == "" {
|
||||
var globalCount int
|
||||
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
|
||||
var globalCampaignID string
|
||||
var globalPercent float64
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, discount_percent FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
||||
AND milestone_value = $1
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
`, globalCount+1).Scan(&globalCampaignID, &globalPercent)
|
||||
|
||||
if globalCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, globalCampaignID)
|
||||
}
|
||||
}
|
||||
|
||||
if milestoneCampaignID == "" {
|
||||
var firstVisitDate time.Time
|
||||
_ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
|
||||
if !firstVisitDate.IsZero() {
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
||||
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
||||
`, booking.User.ID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var annID string
|
||||
var annPercent float64
|
||||
var annValue int
|
||||
var annUnit string
|
||||
if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil {
|
||||
var matches bool
|
||||
elapsed := time.Since(firstVisitDate)
|
||||
switch annUnit {
|
||||
case "months":
|
||||
months := int(elapsed.Hours() / (30 * 24))
|
||||
matches = months >= annValue
|
||||
case "years":
|
||||
years := int(elapsed.Hours() / (365.25 * 24))
|
||||
matches = years >= annValue
|
||||
}
|
||||
if matches {
|
||||
discountAmount := roundTo2(bookingTotal * annPercent / 100)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
||||
`, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, booking.User.ID)
|
||||
_, _ = db.DB.Exec(r.Context(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, annID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paymentCount int
|
||||
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount)
|
||||
if paymentCount > 0 {
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package bookings_test
|
||||
|
||||
// Integration tests for the loyalty/discount system.
|
||||
//
|
||||
// Test Coverage:
|
||||
// - Loyalty auto-redemption: stamps -> pending redemption -> applied discount
|
||||
// - Time-based campaign discounts
|
||||
// - Per-user milestone discounts (10th booking)
|
||||
// - Global milestone discounts
|
||||
// - Anniversary milestone discounts
|
||||
// - Discount priority (loyalty > campaign > milestone)
|
||||
// - Edge cases: zero total, max redemptions, eligibility flag
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/bookings"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupTestDB replaces the global db.DB with a test pool and returns a cleanup function
|
||||
func setupTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
|
||||
pool := testdb.Pool(t)
|
||||
testdb.Migrate(t, pool)
|
||||
testdb.TruncateTables(t, pool)
|
||||
|
||||
// Replace global db.DB with test pool
|
||||
originalDB := db.DB
|
||||
db.DB = pool
|
||||
|
||||
// Initialize JWT for tests
|
||||
jwt.Init()
|
||||
|
||||
return func() {
|
||||
db.DB = originalDB
|
||||
pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultWorkingHours seeds default working hours for tests
|
||||
func seedDefaultWorkingHours(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
hours := []struct {
|
||||
weekday int
|
||||
startTime string
|
||||
endTime string
|
||||
isOpen bool
|
||||
}{
|
||||
{0, "08:00", "20:00", true}, // Monday
|
||||
{1, "08:00", "20:00", true}, // Tuesday
|
||||
{2, "08:00", "20:00", true}, // Wednesday
|
||||
{3, "08:00", "20:00", true}, // Thursday
|
||||
{4, "08:00", "20:00", true}, // Friday
|
||||
{5, "08:00", "20:00", true}, // Saturday
|
||||
{6, "08:00", "20:00", true}, // Sunday
|
||||
}
|
||||
|
||||
for _, h := range hours {
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
|
||||
`, h.weekday, h.startTime, h.endTime, h.isOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed working hours: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeProgressRequest creates a request to progress a booking status
|
||||
func makeProgressRequest(handler http.HandlerFunc, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
// Set up chi routing context for path params
|
||||
rctx := chi.NewRouteContext()
|
||||
if idx := strings.LastIndex(path, "/"); idx > 0 {
|
||||
rctx.URLParams.Add("id", path[idx+1:])
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
|
||||
// Set user context for admin
|
||||
if token != "" {
|
||||
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-001")
|
||||
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
||||
}
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// createTestUser creates a test user with optional loyalty stamps
|
||||
func createTestUser(t *testing.T, stamps int) string {
|
||||
t.Helper()
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
if stamps > 0 {
|
||||
_, err := db.DB.Exec(context.Background(), "UPDATE users SET loyalty_stamps = $1 WHERE id = $2", stamps, userID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return userID
|
||||
}
|
||||
|
||||
// createTestService creates a test service with a specific price
|
||||
func createTestService(t *testing.T, price float64) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var serviceID string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`, "Test Service", "A test service", price, 60, true, 16).Scan(&serviceID)
|
||||
require.NoError(t, err)
|
||||
return serviceID
|
||||
}
|
||||
|
||||
// createTestCampaign creates a discount campaign with the specified parameters
|
||||
func createTestCampaign(t *testing.T, name, campaignType string, percent float64, milestoneType, milestoneUnit *string, milestoneValue *int, maxRedemptions *int) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var id string
|
||||
now := time.Now()
|
||||
startDate := now.Add(-24 * time.Hour)
|
||||
endDate := now.Add(24 * time.Hour)
|
||||
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit, max_redemptions, times_redeemed)
|
||||
VALUES ($1, $2, $3, 'active', $4, $5, $6, $7, $8, $9, 0)
|
||||
RETURNING id
|
||||
`, name, campaignType, percent, startDate, endDate, milestoneType, milestoneValue, milestoneUnit, maxRedemptions).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// createCompletedBooking creates a completed booking directly in the database
|
||||
func createCompletedBooking(t *testing.T, userID, serviceID string, startTime time.Time, price float64) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var bookingID string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'completed')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO booking_services (booking_id, service_id, override_price)
|
||||
VALUES ($1, $2, $3)
|
||||
`, bookingID, serviceID, price)
|
||||
require.NoError(t, err)
|
||||
|
||||
return bookingID
|
||||
}
|
||||
|
||||
// createPendingBooking creates a pending booking
|
||||
func createPendingBooking(t *testing.T, userID, serviceID string, startTime time.Time) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var bookingID string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'confirmed')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO booking_services (booking_id, service_id)
|
||||
VALUES ($1, $2)
|
||||
`, bookingID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return bookingID
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Loyalty Auto-Redemption Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_LoyaltyAutoRedemption tests the full loyalty stamp -> redemption -> discount flow
|
||||
func TestDiscount_LoyaltyAutoRedemption(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Step 1: Create user with 9 stamps
|
||||
userID := createTestUser(t, 9)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// Step 2: Create and complete first booking (adds 1 stamp -> 10)
|
||||
bookingID1 := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify pending loyalty_redemption was created
|
||||
var redemptionCount int
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'
|
||||
`, userID).Scan(&redemptionCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, redemptionCount, "Expected 1 pending loyalty redemption")
|
||||
|
||||
// Verify user now has 10 stamps
|
||||
var stamps int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, stamps, "Expected 10 stamps after first completed booking")
|
||||
|
||||
// Step 3: Create and complete second booking (should apply discount)
|
||||
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
||||
|
||||
w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion")
|
||||
|
||||
// Verify booking_discounts row exists with loyalty source
|
||||
var discountCount int
|
||||
var discountSource string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*), discount_source FROM booking_discounts WHERE booking_id = $1 GROUP BY discount_source
|
||||
`, bookingID2).Scan(&discountCount, &discountSource)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, discountCount, "Expected 1 discount applied")
|
||||
assert.Equal(t, "loyalty", discountSource, "Expected discount source to be 'loyalty'")
|
||||
|
||||
// Verify stamps reduced to 0
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, stamps, "Expected 0 stamps after redemption applied")
|
||||
|
||||
// Verify redemption status = 'applied'
|
||||
var redemptionStatus string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied'
|
||||
`, userID).Scan(&redemptionStatus)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "applied", redemptionStatus, "Expected redemption status to be 'applied'")
|
||||
|
||||
// Verify discount payment was created
|
||||
var paymentMethod string
|
||||
var paymentAmount float64
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT payment_method, amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
||||
`, bookingID2).Scan(&paymentMethod, &paymentAmount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "discount", paymentMethod, "Expected payment method to be 'discount'")
|
||||
assert.Equal(t, 5.00, paymentAmount, "Expected 10% discount on £50 booking") // 10% of 50 = 5
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Time-Based Campaign Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_TimeBasedCampaign tests that time-based campaign discounts are applied
|
||||
func TestDiscount_TimeBasedCampaign(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create active time-based campaign
|
||||
campaignID := createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 100.00)
|
||||
|
||||
// Create and complete booking
|
||||
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify booking_discounts row with campaign source
|
||||
var discountSource, campaignType string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
SELECT discount_source, campaign_type FROM booking_discounts WHERE booking_id = $1
|
||||
`, bookingID).Scan(&discountSource, &campaignType)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "campaign", discountSource, "Expected discount source to be 'campaign'")
|
||||
assert.Equal(t, "time_based", campaignType, "Expected campaign type to be 'time_based'")
|
||||
|
||||
// Verify campaign times_redeemed incremented
|
||||
var timesRedeemed int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, timesRedeemed, "Expected times_redeemed to be 1")
|
||||
|
||||
// Verify discount payment
|
||||
var paymentAmount float64
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
||||
`, bookingID).Scan(&paymentAmount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5.00, paymentAmount, "Expected 5% discount on £100 booking") // 5% of 100 = 5
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-User Milestone Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_PerUserMilestone tests that per-user booking count milestones work
|
||||
func TestDiscount_PerUserMilestone(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create "10th booking" per-user milestone campaign
|
||||
milestoneValue := 10
|
||||
milestoneType := "per_user_booking_count"
|
||||
campaignID := createTestCampaign(t, "10th Visit Bonus", "milestone", 15.0, &milestoneType, nil, &milestoneValue, nil)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 80.00)
|
||||
|
||||
// Create 9 completed bookings (direct SQL to avoid triggering discounts)
|
||||
for i := 0; i < 9; i++ {
|
||||
startTime := time.Now().AddDate(0, -1, -i*7) // Space them out over time
|
||||
_ = createCompletedBooking(t, userID, serviceID, startTime, 80.00)
|
||||
}
|
||||
|
||||
// Create and complete the 10th booking
|
||||
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify discount applied with milestone_type='per_user_booking_count'
|
||||
var discountSource, milestoneTypeResult string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
SELECT discount_source, milestone_type FROM booking_discounts WHERE booking_id = $1
|
||||
`, bookingID).Scan(&discountSource, &milestoneTypeResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "campaign", discountSource, "Expected discount source to be 'campaign'")
|
||||
assert.Equal(t, "per_user_booking_count", milestoneTypeResult, "Expected milestone type 'per_user_booking_count'")
|
||||
|
||||
// Verify discount amount (15% of £80 = £12)
|
||||
var discountAmount float64
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 12.00, discountAmount, "Expected 15% discount (£12)")
|
||||
|
||||
// Complete another booking - verify NO second discount (dedup works)
|
||||
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
||||
w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion")
|
||||
|
||||
// Should have no discount (already used this milestone)
|
||||
var discountCount int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, discountCount, "Expected no discount on 11th booking (dedup)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Global Milestone Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_GlobalMilestone tests that global booking count milestones work
|
||||
func TestDiscount_GlobalMilestone(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create global milestone campaign for 5th total booking
|
||||
milestoneValue := 5
|
||||
milestoneType := "global_booking_count"
|
||||
campaignID := createTestCampaign(t, "5th Customer Milestone", "milestone", 20.0, &milestoneType, nil, &milestoneValue, nil)
|
||||
|
||||
userID1 := createTestUser(t, 0)
|
||||
userID2 := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// Create 4 completed bookings (any users)
|
||||
for i := 0; i < 4; i++ {
|
||||
startTime := time.Now().AddDate(0, 0, -i-1)
|
||||
_ = createCompletedBooking(t, userID1, serviceID, startTime, 50.00)
|
||||
}
|
||||
|
||||
// Create and complete the 5th booking
|
||||
bookingID := createPendingBooking(t, userID2, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify discount applied with milestone_type='global_booking_count'
|
||||
var milestoneTypeResult string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
SELECT milestone_type FROM booking_discounts WHERE booking_id = $1
|
||||
`, bookingID).Scan(&milestoneTypeResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "global_booking_count", milestoneTypeResult, "Expected milestone type 'global_booking_count'")
|
||||
|
||||
// Verify discount amount (20% of £50 = £10)
|
||||
var discountAmount float64
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10.00, discountAmount, "Expected 20% discount (£10)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Anniversary Milestone Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_AnniversaryMilestone tests that anniversary-based milestones work
|
||||
func TestDiscount_AnniversaryMilestone(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create anniversary campaign (6 months)
|
||||
milestoneValue := 6
|
||||
milestoneType := "anniversary"
|
||||
milestoneUnit := "months"
|
||||
campaignID := createTestCampaign(t, "6 Month Anniversary", "milestone", 10.0, &milestoneType, &milestoneUnit, &milestoneValue, nil)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 60.00)
|
||||
|
||||
// Create first booking 7 months ago (using direct SQL to set start_time)
|
||||
ctx := context.Background()
|
||||
sevenMonthsAgo := time.Now().AddDate(0, -7, 0)
|
||||
var firstBookingID string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'completed')
|
||||
RETURNING id
|
||||
`, userID, sevenMonthsAgo).Scan(&firstBookingID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO booking_services (booking_id, service_id, override_price)
|
||||
VALUES ($1, $2, $3)
|
||||
`, firstBookingID, serviceID, 60.00)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and complete new booking
|
||||
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify discount applied with milestone_type='anniversary'
|
||||
var milestoneTypeResult string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT milestone_type FROM booking_discounts WHERE booking_id = $1
|
||||
`, bookingID).Scan(&milestoneTypeResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "anniversary", milestoneTypeResult, "Expected milestone type 'anniversary'")
|
||||
|
||||
// Complete another booking - verify NO second discount (dedup via booking_discounts)
|
||||
bookingID2 := createPendingBooking(t, userID, serviceID, time.Now().Add(48*time.Hour))
|
||||
w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion")
|
||||
|
||||
var discountCount int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, discountCount, "Expected no discount on second booking (dedup)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Loyalty Priority Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_LoyaltyPriority tests that loyalty discounts take priority over campaigns
|
||||
func TestDiscount_LoyaltyPriority(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create active time-based campaign
|
||||
_ = createTestCampaign(t, "Summer Sale", "time_based", 5.0, nil, nil, nil, nil)
|
||||
|
||||
// Create user with 10 stamps (pending redemption)
|
||||
userID := createTestUser(t, 10)
|
||||
|
||||
// Create pending redemption
|
||||
ctx := context.Background()
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
||||
VALUES ($1, 10, 'pending', NOW())
|
||||
`, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceID := createTestService(t, 100.00)
|
||||
|
||||
// Create and complete booking
|
||||
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify ONLY loyalty discount applied (not campaign)
|
||||
var discountSource string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT discount_source FROM booking_discounts WHERE booking_id = $1
|
||||
`, bookingID).Scan(&discountSource)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "loyalty", discountSource, "Expected only loyalty discount (priority)")
|
||||
|
||||
// Verify NOT campaign
|
||||
var campaignDiscountCount int
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign'
|
||||
`, bookingID).Scan(&campaignDiscountCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, campaignDiscountCount, "Expected no campaign discount (loyalty takes priority)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Zero Total Edge Case Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_NoDiscountOnZeroTotal tests that no discount is applied when booking total is 0
|
||||
func TestDiscount_NoDiscountOnZeroTotal(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create user with 10 stamps (pending redemption)
|
||||
userID := createTestUser(t, 10)
|
||||
|
||||
// Create pending redemption
|
||||
ctx := context.Background()
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
||||
VALUES ($1, 10, 'pending', NOW())
|
||||
`, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a free service (price = 0)
|
||||
var serviceID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`, "Free Service", "A free service", 0.00, 60, true, 16).Scan(&serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and complete booking with £0 total
|
||||
bookingID := createPendingBooking(t, userID, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on booking completion")
|
||||
|
||||
// Verify NO discount payment created (booking total is 0, discount would be 0)
|
||||
var paymentCount int
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
|
||||
`, bookingID).Scan(&paymentCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, paymentCount, "Expected no discount payment (total is 0)")
|
||||
|
||||
// Verify redemption stays pending (not applied)
|
||||
var redemptionStatus string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT status FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending'
|
||||
`, userID).Scan(&redemptionStatus)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pending", redemptionStatus, "Expected redemption to stay pending")
|
||||
|
||||
// Verify stamps still at 10
|
||||
var stamps int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, stamps, "Expected stamps to remain at 10")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Max Redemptions Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_CampaignMaxRedemptions tests that campaigns respect max_redemptions limit
|
||||
func TestDiscount_CampaignMaxRedemptions(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create campaign with max_redemptions=1
|
||||
maxRedemptions := 1
|
||||
campaignID := createTestCampaign(t, "Limited Time Offer", "time_based", 10.0, nil, nil, nil, &maxRedemptions)
|
||||
|
||||
userID1 := createTestUser(t, 0)
|
||||
userID2 := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// First booking - should get discount
|
||||
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
progressReq := bookings.ProgressBookingRequest{Status: "completed"}
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID1+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on first booking completion")
|
||||
|
||||
// Verify discount applied
|
||||
var discountCount1 int
|
||||
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID1).Scan(&discountCount1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, discountCount1, "Expected discount on first booking")
|
||||
|
||||
// Verify times_redeemed = 1
|
||||
var timesRedeemed int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(×Redeemed)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, timesRedeemed, "Expected times_redeemed = 1")
|
||||
|
||||
// Second booking - should NOT get discount (max reached)
|
||||
bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour))
|
||||
w = makeProgressRequest(handler, "PUT", "/api/admin/bookings/"+bookingID2+"/progress", progressReq, "admin-token")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "Expected 200 on second booking completion")
|
||||
|
||||
// Verify NO discount applied
|
||||
var discountCount2 int
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID2).Scan(&discountCount2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, discountCount2, "Expected no discount on second booking (max reached)")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discount Eligibility Flag Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestDiscount_EligibilityFlag tests that booking.discount_eligible is set correctly
|
||||
func TestDiscount_EligibilityFlag(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Test case 1: User with pending redemption should be eligible
|
||||
userID1 := createTestUser(t, 10)
|
||||
|
||||
// Create pending redemption
|
||||
ctx := context.Background()
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
||||
VALUES ($1, 10, 'pending', NOW())
|
||||
`, userID1)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// Create a new booking
|
||||
bookingID1 := createPendingBooking(t, userID1, serviceID, time.Now().Add(24*time.Hour))
|
||||
|
||||
// Check discount_eligible flag
|
||||
var discountEligible1 bool
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID1).Scan(&discountEligible1)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, discountEligible1, "Expected discount_eligible = true for user with pending redemption")
|
||||
|
||||
// Test case 2: User with no pending redemption and no active campaigns should NOT be eligible
|
||||
userID2 := createTestUser(t, 0) // No stamps, no pending redemption
|
||||
|
||||
bookingID2 := createPendingBooking(t, userID2, serviceID, time.Now().Add(48*time.Hour))
|
||||
|
||||
var discountEligible2 bool
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID2).Scan(&discountEligible2)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, discountEligible2, "Expected discount_eligible = false for user with no pending redemption and no campaigns")
|
||||
|
||||
// Test case 3: User with active time-based campaign should be eligible
|
||||
userID3 := createTestUser(t, 0)
|
||||
|
||||
// Create active time-based campaign
|
||||
_ = createTestCampaign(t, "Active Campaign", "time_based", 5.0, nil, nil, nil, nil)
|
||||
|
||||
bookingID3 := createPendingBooking(t, userID3, serviceID, time.Now().Add(72*time.Hour))
|
||||
|
||||
var discountEligible3 bool
|
||||
err = db.DB.QueryRow(context.Background(), `SELECT discount_eligible FROM bookings WHERE id = $1`, bookingID3).Scan(&discountEligible3)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, discountEligible3, "Expected discount_eligible = true when active time-based campaign exists")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper: Truncate discount-specific tables
|
||||
// =============================================================================
|
||||
|
||||
// truncateDiscountTables truncates discount-related tables that aren't in the standard truncate list
|
||||
func truncateDiscountTables(t *testing.T, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
tables := []string{
|
||||
"loyalty_redemptions",
|
||||
"discount_campaigns",
|
||||
"booking_discounts",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
_, err := pool.Exec(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not truncate %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -667,6 +667,21 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
|
||||
// Check discount eligibility
|
||||
var discountEligible bool
|
||||
var hasPendingRedemption bool
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM loyalty_redemptions WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW())
|
||||
`, req.UserID).Scan(&hasPendingRedemption)
|
||||
var activeCampaigns int
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM discount_campaigns
|
||||
WHERE status = 'active' AND campaign_type = 'time_based'
|
||||
AND start_date <= NOW() AND end_date >= NOW()
|
||||
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
||||
`).Scan(&activeCampaigns)
|
||||
discountEligible = hasPendingRedemption || activeCampaigns > 0
|
||||
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
@@ -683,9 +698,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
status,
|
||||
notes,
|
||||
created_by,
|
||||
idempotency_key
|
||||
idempotency_key,
|
||||
discount_eligible
|
||||
)
|
||||
VALUES ($1, $2, 'confirmed', $3, $4, $5)
|
||||
VALUES ($1, $2, 'confirmed', $3, $4, $5, $6)
|
||||
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
||||
`
|
||||
|
||||
@@ -700,6 +716,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
req.Notes,
|
||||
adminID,
|
||||
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
|
||||
discountEligible,
|
||||
).Scan(
|
||||
&booking.ID,
|
||||
&booking.User.ID,
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"crussell/mw"
|
||||
|
||||
authHandlers "crussell/handlers/auth"
|
||||
"crussell/handlers/admin"
|
||||
"crussell/handlers/bookings"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/portfolio"
|
||||
@@ -270,6 +271,14 @@ r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Post("/", scheduling.CreateTimeBlocker)
|
||||
r.Delete("/{id}", scheduling.DeleteTimeBlocker)
|
||||
})
|
||||
|
||||
r.Route("/admin/discount-campaigns", func(r chi.Router) {
|
||||
r.Get("/", admin.GetDiscountCampaigns)
|
||||
r.Post("/", admin.CreateDiscountCampaign)
|
||||
r.Put("/{id}", admin.UpdateDiscountCampaign)
|
||||
r.Delete("/{id}", admin.DeleteDiscountCampaign)
|
||||
r.Get("/{id}/stats", admin.GetCampaignStats)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface BookingUser {
|
||||
date_of_birth?: string;
|
||||
account_role: string;
|
||||
loyalty_stamps?: number;
|
||||
pending_redemption?: boolean;
|
||||
referral_code?: string;
|
||||
referral_code_uses?: number;
|
||||
created_at: string;
|
||||
@@ -120,6 +121,7 @@ export interface Booking {
|
||||
amount_paid: number;
|
||||
amount_due: number;
|
||||
duration_minutes: number;
|
||||
discount_eligible?: boolean;
|
||||
}
|
||||
|
||||
export interface BookingListResponse {
|
||||
@@ -130,3 +132,60 @@ export interface BookingListResponse {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface LoyaltyRedemption {
|
||||
id: string;
|
||||
user_id: string;
|
||||
stamps_redeemed: number;
|
||||
status: 'pending' | 'applied' | 'expired';
|
||||
applied_to_booking_id?: string;
|
||||
redeemed_at: string;
|
||||
applied_at?: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export type CampaignType = 'time_based' | 'milestone';
|
||||
export type MilestoneType = 'per_user_booking_count' | 'global_booking_count' | 'anniversary';
|
||||
export type MilestoneUnit = 'bookings' | 'months' | 'years';
|
||||
export type DiscountCampaignScope = 'all_bookings' | 'first_booking_only' | 'new_customers_only';
|
||||
export type DiscountCampaignStatus = 'draft' | 'active' | 'completed' | 'cancelled';
|
||||
|
||||
export interface DiscountCampaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
campaign_type: CampaignType;
|
||||
discount_percent: number;
|
||||
scope?: DiscountCampaignScope;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
milestone_type?: MilestoneType;
|
||||
milestone_value?: number;
|
||||
milestone_unit?: MilestoneUnit;
|
||||
status: DiscountCampaignStatus;
|
||||
max_redemptions?: number;
|
||||
times_redeemed: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BookingDiscount {
|
||||
id: string;
|
||||
booking_id: string;
|
||||
user_id: string;
|
||||
discount_source: 'loyalty' | 'campaign';
|
||||
source_id?: string;
|
||||
campaign_type?: CampaignType;
|
||||
milestone_type?: MilestoneType;
|
||||
discount_percent: number;
|
||||
original_total: number;
|
||||
discount_amount: number;
|
||||
applied_at: string;
|
||||
}
|
||||
|
||||
export interface CampaignStats {
|
||||
campaign: DiscountCampaign;
|
||||
total_discount_amount: number;
|
||||
booking_count: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
let userData = $state<User | null>(null);
|
||||
let loadingUser = $state(true);
|
||||
let stamps = $state(0);
|
||||
let pendingRedemption = $state(false);
|
||||
let uploadingPic = $state(false);
|
||||
|
||||
// Image cropper state
|
||||
@@ -287,6 +288,7 @@
|
||||
const data = await response.json();
|
||||
userData = data;
|
||||
stamps = userData?.loyaltyStamps ?? 0;
|
||||
pendingRedemption = stamps >= 10;
|
||||
} else {
|
||||
toast.error('Failed to load profile data');
|
||||
}
|
||||
@@ -832,10 +834,10 @@
|
||||
<div class="text-3xl font-bold text-emerald-700">
|
||||
{10 - stamps}
|
||||
</div>
|
||||
{:else}
|
||||
{:else if userData && stamps >= 10}
|
||||
<div class="w-full text-center">
|
||||
<div class="text-sm font-medium text-emerald-800">
|
||||
Congratulations! You've earned 10% off your next appointment!
|
||||
Your loyalty card is full! Your next completed appointment will receive 10% off.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,978 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// UI Components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Label from '$lib/components/ui/label';
|
||||
import * as Textarea from '$lib/components/ui/textarea';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
|
||||
// Types from booking.ts
|
||||
import type {
|
||||
DiscountCampaign,
|
||||
CampaignStats,
|
||||
CampaignType,
|
||||
MilestoneType,
|
||||
MilestoneUnit,
|
||||
DiscountCampaignScope,
|
||||
DiscountCampaignStatus
|
||||
} from '$lib/types/booking';
|
||||
|
||||
// =============== Auth & Permissions ===============
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
if (authStore.isLoading) {
|
||||
pageState = 'loading';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
pageState = 'unauthorized';
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.currentUser?.role !== 'admin') {
|
||||
pageState = 'unauthorized';
|
||||
goto('/', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
pageState = 'authorized';
|
||||
});
|
||||
|
||||
// =============== State ===============
|
||||
let campaigns = $state<DiscountCampaign[]>([]);
|
||||
let campaignsLoading = $state(true);
|
||||
let campaignActionInProgress = $state<string | null>(null);
|
||||
|
||||
// Create Modal State
|
||||
let showCreateModal = $state(false);
|
||||
let creatingCampaign = $state(false);
|
||||
let newCampaign = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
campaign_type: 'time_based' as CampaignType,
|
||||
discount_percent: 10,
|
||||
scope: 'all_bookings' as DiscountCampaignScope,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
milestone_type: 'per_user_booking_count' as MilestoneType,
|
||||
milestone_value: 0,
|
||||
milestone_unit: 'bookings' as MilestoneUnit,
|
||||
max_redemptions: 0
|
||||
});
|
||||
|
||||
let formErrors = $state<Record<string, string>>({});
|
||||
|
||||
// Stats Modal State
|
||||
let showStatsModal = $state(false);
|
||||
let selectedCampaign = $state<DiscountCampaign | null>(null);
|
||||
let campaignStats = $state<CampaignStats | null>(null);
|
||||
let statsLoading = $state(false);
|
||||
|
||||
// =============== Validations ===============
|
||||
function validateName(name: string): string {
|
||||
if (!name.trim()) return 'Campaign name is required';
|
||||
if (name.length > 100) return 'Name must be 100 characters or less';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateDiscount(percent: number): string {
|
||||
if (isNaN(percent)) return 'Discount must be a valid number';
|
||||
if (percent <= 0) return 'Discount must be greater than 0';
|
||||
if (percent > 100) return 'Discount must be 100 or less';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateDates(start: string, end: string): string {
|
||||
if (!start) return 'Start date is required';
|
||||
if (!end) return 'End date is required';
|
||||
const startTime = new Date(start).getTime();
|
||||
const endTime = new Date(end).getTime();
|
||||
if (isNaN(startTime)) return 'Invalid start date format';
|
||||
if (isNaN(endTime)) return 'Invalid end date format';
|
||||
if (endTime <= startTime) return 'End date must be after start date';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateMilestone(value: number, type: MilestoneType | undefined): string {
|
||||
if (type === undefined) return '';
|
||||
if (value <= 0) return 'Milestone value must be greater than 0';
|
||||
return '';
|
||||
}
|
||||
|
||||
let isFormValid = $derived.by(() => {
|
||||
const nameError = validateName(newCampaign.name);
|
||||
const discountError = validateDiscount(newCampaign.discount_percent);
|
||||
|
||||
if (nameError || discountError) return false;
|
||||
|
||||
if (newCampaign.campaign_type === 'time_based') {
|
||||
const dateError = validateDates(newCampaign.start_date, newCampaign.end_date);
|
||||
if (dateError) return false;
|
||||
}
|
||||
|
||||
if (newCampaign.campaign_type === 'milestone') {
|
||||
const milestoneError = validateMilestone(newCampaign.milestone_value, newCampaign.milestone_type);
|
||||
if (milestoneError) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// =============== API Functions ===============
|
||||
async function fetchCampaigns() {
|
||||
campaignsLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/admin/discount-campaigns', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
campaigns = data;
|
||||
} else {
|
||||
toast.error('Failed to load campaigns');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching campaigns:', err);
|
||||
toast.error('Network error loading campaigns');
|
||||
} finally {
|
||||
campaignsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateCampaign(campaignId: string) {
|
||||
campaignActionInProgress = campaignId;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/discount-campaigns/${campaignId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({ status: 'active' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Campaign activated');
|
||||
await fetchCampaigns();
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to activate: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error activating campaign:', err);
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
campaignActionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCampaign(campaignId: string) {
|
||||
campaignActionInProgress = campaignId;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/discount-campaigns/${campaignId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({ status: 'completed' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Campaign completed');
|
||||
await fetchCampaigns();
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to complete: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error completing campaign:', err);
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
campaignActionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCampaign(campaignId: string) {
|
||||
if (!confirm('Are you sure you want to cancel this campaign? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
campaignActionInProgress = campaignId;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/discount-campaigns/${campaignId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Campaign cancelled');
|
||||
await fetchCampaigns();
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to cancel: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error cancelling campaign:', err);
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
campaignActionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCampaignStats(campaignId: string) {
|
||||
statsLoading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/discount-campaigns/${campaignId}/stats`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
campaignStats = await response.json();
|
||||
} else {
|
||||
toast.error('Failed to load campaign stats');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
toast.error('Network error loading stats');
|
||||
} finally {
|
||||
statsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createCampaign() {
|
||||
// Validate
|
||||
const nameError = validateName(newCampaign.name);
|
||||
const discountError = validateDiscount(newCampaign.discount_percent);
|
||||
|
||||
if (nameError || discountError) {
|
||||
toast.error('Please fix validation errors');
|
||||
return;
|
||||
}
|
||||
|
||||
creatingCampaign = true;
|
||||
const loadingToast = toast.loading('Creating campaign...');
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: newCampaign.name.trim(),
|
||||
description: newCampaign.description.trim() || undefined,
|
||||
campaign_type: newCampaign.campaign_type,
|
||||
discount_percent: newCampaign.discount_percent
|
||||
};
|
||||
|
||||
if (newCampaign.campaign_type === 'time_based') {
|
||||
payload.scope = newCampaign.scope;
|
||||
payload.start_date = new Date(newCampaign.start_date).toISOString();
|
||||
payload.end_date = new Date(newCampaign.end_date).toISOString();
|
||||
} else if (newCampaign.campaign_type === 'milestone') {
|
||||
payload.milestone_type = newCampaign.milestone_type;
|
||||
payload.milestone_value = newCampaign.milestone_value;
|
||||
payload.milestone_unit = newCampaign.milestone_unit;
|
||||
}
|
||||
|
||||
if (newCampaign.max_redemptions > 0) {
|
||||
payload.max_redemptions = newCampaign.max_redemptions;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/discount-campaigns', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Campaign created successfully!', { id: loadingToast });
|
||||
resetForm();
|
||||
showCreateModal = false;
|
||||
await fetchCampaigns();
|
||||
} else if (response.status === 400) {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to create: ${errorText}`, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating campaign:', err);
|
||||
toast.error('Network error', { id: loadingToast });
|
||||
} finally {
|
||||
creatingCampaign = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newCampaign = {
|
||||
name: '',
|
||||
description: '',
|
||||
campaign_type: 'time_based',
|
||||
discount_percent: 10,
|
||||
scope: 'all_bookings',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
milestone_type: 'per_user_booking_count',
|
||||
milestone_value: 0,
|
||||
milestone_unit: 'bookings',
|
||||
max_redemptions: 0
|
||||
};
|
||||
formErrors = {};
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
async function openStatsModal(campaign: DiscountCampaign) {
|
||||
selectedCampaign = campaign;
|
||||
campaignStats = null;
|
||||
showStatsModal = true;
|
||||
await fetchCampaignStats(campaign.id);
|
||||
}
|
||||
|
||||
// =============== Helpers ===============
|
||||
function getStatusBadgeVariant(status: DiscountCampaignStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'secondary';
|
||||
case 'active':
|
||||
return 'default';
|
||||
case 'completed':
|
||||
return 'outline';
|
||||
case 'cancelled':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getCampaignTypeLabel(type: CampaignType, milestoneType?: MilestoneType): string {
|
||||
if (type === 'time_based') return 'Time-based';
|
||||
if (type === 'milestone') {
|
||||
switch (milestoneType) {
|
||||
case 'per_user_booking_count':
|
||||
return 'Milestone: Per-user';
|
||||
case 'global_booking_count':
|
||||
return 'Milestone: Global';
|
||||
case 'anniversary':
|
||||
return 'Milestone: Anniversary';
|
||||
default:
|
||||
return 'Milestone';
|
||||
}
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
// =============== Lifecycle ===============
|
||||
$effect(() => {
|
||||
if (pageState === 'authorized') {
|
||||
fetchCampaigns();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if pageState === 'loading'}
|
||||
<!-- Loading Skeleton -->
|
||||
<div class="mx-auto max-w-6xl space-y-6 p-6">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-8 w-64" />
|
||||
<Skeleton class="h-4 w-96" />
|
||||
</div>
|
||||
<Skeleton class="h-10 w-40" />
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Skeleton class="h-6 w-48" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<table class="w-full table-auto">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="py-3"><Skeleton class="h-4 w-24" /></th>
|
||||
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
|
||||
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
|
||||
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
|
||||
<th class="py-3"><Skeleton class="h-4 w-24" /></th>
|
||||
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Array(3) as _, i (i)}
|
||||
<tr class="border-b">
|
||||
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
|
||||
<td class="py-3"><Skeleton class="h-5 w-20" /></td>
|
||||
<td class="py-3"><Skeleton class="h-4 w-12" /></td>
|
||||
<td class="py-3"><Skeleton class="h-5 w-16" /></td>
|
||||
<td class="py-3"><Skeleton class="h-4 w-8" /></td>
|
||||
<td class="py-3">
|
||||
<div class="flex gap-2">
|
||||
<Skeleton class="h-8 w-20" />
|
||||
<Skeleton class="h-8 w-16" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else if pageState === 'authorized'}
|
||||
<div class="mx-auto max-w-6xl space-y-6 p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Discount Campaigns</h1>
|
||||
<p class="text-gray-600">Create and manage discount campaigns for your customers</p>
|
||||
</div>
|
||||
<Button onclick={openCreateModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Create Campaign
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Campaigns List -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>All Campaigns</Card.Title>
|
||||
<Card.Description>View and manage your discount campaigns</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<!-- Desktop Table -->
|
||||
<div class="hidden w-full overflow-x-auto md:block">
|
||||
<table class="w-full table-auto border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="w-[25%] py-3 font-medium">Name</th>
|
||||
<th class="w-[15%] py-3 font-medium">Type</th>
|
||||
<th class="w-[10%] py-3 text-right font-medium">Discount</th>
|
||||
<th class="w-[12%] py-3 text-center font-medium">Status</th>
|
||||
<th class="w-[12%] py-3 text-center font-medium">Redeemed</th>
|
||||
<th class="w-[26%] py-3 text-center font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if campaignsLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<tr class="border-b">
|
||||
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
|
||||
<td class="py-3"><Skeleton class="h-5 w-20" /></td>
|
||||
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-12" /></td>
|
||||
<td class="py-3 text-center"><Skeleton class="mx-auto h-5 w-16" /></td>
|
||||
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-8" /></td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center gap-2">
|
||||
<Skeleton class="h-8 w-20" />
|
||||
<Skeleton class="h-8 w-16" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each campaigns as campaign (campaign.id)}
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="py-3">
|
||||
<div class="font-medium">{campaign.name}</div>
|
||||
{#if campaign.description}
|
||||
<div class="text-xs text-gray-500 line-clamp-1">{campaign.description}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800"
|
||||
>
|
||||
{getCampaignTypeLabel(
|
||||
campaign.campaign_type,
|
||||
campaign.milestone_type as MilestoneType
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 text-right font-medium">{campaign.discount_percent}%</td>
|
||||
<td class="py-3 text-center">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {getStatusBadgeVariant(
|
||||
campaign.status
|
||||
) === 'default'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: getStatusBadgeVariant(campaign.status) === 'secondary'
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: getStatusBadgeVariant(campaign.status) === 'destructive'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-blue-100 text-blue-800'}"
|
||||
>
|
||||
{campaign.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
{campaign.times_redeemed}
|
||||
{campaign.max_redemptions ? `/${campaign.max_redemptions}` : '/∞'}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center gap-2">
|
||||
{#if campaign.status === 'draft'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => activateCampaign(campaign.id)}
|
||||
disabled={campaignActionInProgress === campaign.id}
|
||||
>
|
||||
{campaignActionInProgress === campaign.id
|
||||
? '...'
|
||||
: 'Activate'}
|
||||
</Button>
|
||||
{:else if campaign.status === 'active'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => completeCampaign(campaign.id)}
|
||||
disabled={campaignActionInProgress === campaign.id}
|
||||
>
|
||||
{campaignActionInProgress === campaign.id
|
||||
? '...'
|
||||
: 'Complete'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
class="opacity-50"
|
||||
>
|
||||
{campaign.status}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openStatsModal(campaign)}
|
||||
>
|
||||
Stats
|
||||
</Button>
|
||||
{#if campaign.status !== 'cancelled'}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => cancelCampaign(campaign.id)}
|
||||
disabled={campaignActionInProgress === campaign.id}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div class="space-y-4 md:hidden">
|
||||
{#if campaignsLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="space-y-3">
|
||||
<Skeleton class="h-5 w-32" />
|
||||
<Skeleton class="h-4 w-48" />
|
||||
<div class="flex gap-2">
|
||||
<Skeleton class="h-8 w-16" />
|
||||
<Skeleton class="h-8 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each campaigns as campaign (campaign.id)}
|
||||
<div class="rounded-lg border p-4 hover:bg-gray-50">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium">{campaign.name}</h3>
|
||||
{#if campaign.description}
|
||||
<p class="text-sm text-gray-500">{campaign.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {getStatusBadgeVariant(
|
||||
campaign.status
|
||||
) === 'default'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: getStatusBadgeVariant(campaign.status) === 'secondary'
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: getStatusBadgeVariant(campaign.status) === 'destructive'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-blue-100 text-blue-800'}"
|
||||
>
|
||||
{campaign.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<span class="font-medium">{campaign.discount_percent}% off</span>
|
||||
<span class="text-gray-500">•</span>
|
||||
<span class="text-gray-600">
|
||||
{getCampaignTypeLabel(
|
||||
campaign.campaign_type,
|
||||
campaign.milestone_type as MilestoneType
|
||||
)}
|
||||
</span>
|
||||
<span class="text-gray-500">•</span>
|
||||
<span class="text-gray-600">
|
||||
{campaign.times_redeemed}
|
||||
{campaign.max_redemptions ? `/${campaign.max_redemptions}` : '/∞'}
|
||||
redeemed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-2">
|
||||
{#if campaign.status === 'draft'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => activateCampaign(campaign.id)}
|
||||
disabled={campaignActionInProgress === campaign.id}
|
||||
class="flex-1"
|
||||
>
|
||||
{campaignActionInProgress === campaign.id ? '...' : 'Activate'}
|
||||
</Button>
|
||||
{:else if campaign.status === 'active'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => completeCampaign(campaign.id)}
|
||||
disabled={campaignActionInProgress === campaign.id}
|
||||
class="flex-1"
|
||||
>
|
||||
{campaignActionInProgress === campaign.id ? '...' : 'Complete'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openStatsModal(campaign)}
|
||||
class="flex-1"
|
||||
>
|
||||
Stats
|
||||
</Button>
|
||||
{#if campaign.status !== 'cancelled'}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => cancelCampaign(campaign.id)}
|
||||
disabled={campaignActionInProgress === campaign.id}
|
||||
class="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !campaignsLoading && campaigns.length === 0}
|
||||
<div class="py-8 text-center text-gray-500">
|
||||
No campaigns found. Click "Create Campaign" to create your first campaign.
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Create Campaign Modal -->
|
||||
<Modal.Root bind:open={showCreateModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Create Campaign</Modal.Title>
|
||||
<Modal.Description>Create a new discount campaign for your customers.</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<!-- Campaign Name -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="campaign-name">Campaign Name *</Label.Root>
|
||||
<Input
|
||||
id="campaign-name"
|
||||
type="text"
|
||||
maxlength={100}
|
||||
placeholder="e.g., Summer Sale, New Year Discount"
|
||||
bind:value={newCampaign.name}
|
||||
/>
|
||||
{#if formErrors.name}
|
||||
<p class="text-sm text-red-600">{formErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="milestone-unit">Milestone Unit</Label.Root>
|
||||
<Select.Root type="single" value={newCampaign.milestone_unit} onValueChange={(v: string) => { newCampaign.milestone_unit = v as MilestoneUnit; }}>
|
||||
<Select.Trigger id="milestone-unit" disabled />
|
||||
<Select.Content>
|
||||
<Select.Item value="bookings">Bookings</Select.Item>
|
||||
<Select.Item value="months">Months</Select.Item>
|
||||
<Select.Item value="years">Years</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Campaign Type -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="campaign-type">Campaign Type *</Label.Root>
|
||||
<Select.Root type="single" value={newCampaign.campaign_type} onValueChange={(v: string) => { newCampaign.campaign_type = v as CampaignType; }}>
|
||||
<Select.Trigger id="campaign-type" />
|
||||
<Select.Content>
|
||||
<Select.Item value="time_based">Time-based</Select.Item>
|
||||
<Select.Item value="milestone">Milestone</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Discount Percent -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="discount-percent">Discount Percent *</Label.Root>
|
||||
<Input
|
||||
id="discount-percent"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
bind:value={newCampaign.discount_percent}
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Percentage off the booking total</p>
|
||||
</div>
|
||||
|
||||
<!-- Conditional Fields: Time-based -->
|
||||
{#if newCampaign.campaign_type === 'time_based'}
|
||||
<!-- Scope -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="scope">Scope</Label.Root>
|
||||
<Select.Root type="single" value={newCampaign.scope} onValueChange={(v: string) => { newCampaign.scope = v as DiscountCampaignScope; }}>
|
||||
<Select.Trigger id="scope" />
|
||||
<Select.Content>
|
||||
<Select.Item value="all_bookings">All bookings</Select.Item>
|
||||
<Select.Item value="first_booking_only">First booking only</Select.Item>
|
||||
<Select.Item value="new_customers_only">New customers only</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Start Date -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="start-date">Start Date *</Label.Root>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="datetime-local"
|
||||
bind:value={newCampaign.start_date}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="end-date">End Date *</Label.Root>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="datetime-local"
|
||||
bind:value={newCampaign.end_date}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Conditional Fields: Milestone -->
|
||||
{#if newCampaign.campaign_type === 'milestone'}
|
||||
<!-- Milestone Type -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="milestone-type">Milestone Type *</Label.Root>
|
||||
<Select.Root type="single" value={newCampaign.milestone_type} onValueChange={(v: string) => {
|
||||
newCampaign.milestone_type = v as MilestoneType;
|
||||
if (v === 'anniversary') {
|
||||
newCampaign.milestone_unit = 'years';
|
||||
} else if (v === 'global_booking_count') {
|
||||
newCampaign.milestone_unit = 'bookings';
|
||||
} else {
|
||||
newCampaign.milestone_unit = 'bookings';
|
||||
}
|
||||
}}>
|
||||
<Select.Trigger id="milestone-type" />
|
||||
<Select.Content>
|
||||
<Select.Item value="per_user_booking_count"
|
||||
>Per-user booking count</Select.Item
|
||||
>
|
||||
<Select.Item value="global_booking_count">Global booking count</Select.Item>
|
||||
<Select.Item value="anniversary">Anniversary</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Milestone Value -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="milestone-value">Milestone Value *</Label.Root>
|
||||
<Input
|
||||
id="milestone-value"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={newCampaign.milestone_value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Milestone Unit -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="milestone-unit">Milestone Unit</Label.Root>
|
||||
<Select.Root type="single" value={newCampaign.milestone_unit} onValueChange={(v: string) => { newCampaign.milestone_unit = v as MilestoneUnit; }}>
|
||||
<Select.Trigger id="milestone-unit" disabled />
|
||||
<Select.Content>
|
||||
<Select.Item value="bookings">Bookings</Select.Item>
|
||||
<Select.Item value="months">Months</Select.Item>
|
||||
<Select.Item value="years">Years</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Max Redemptions -->
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="max-redemptions">Max Redemptions</Label.Root>
|
||||
<Input
|
||||
id="max-redemptions"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={newCampaign.max_redemptions}
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Leave at 0 for unlimited</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showCreateModal = false;
|
||||
resetForm();
|
||||
}}
|
||||
disabled={creatingCampaign}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={createCampaign} disabled={creatingCampaign || !isFormValid}>
|
||||
{creatingCampaign ? 'Creating...' : 'Create Campaign'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Stats Modal -->
|
||||
<Modal.Root bind:open={showStatsModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">
|
||||
Campaign Stats
|
||||
</Modal.Title>
|
||||
<Modal.Description>
|
||||
{selectedCampaign?.name || 'Campaign'} statistics
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
{#if statsLoading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton class="h-20 w-full" />
|
||||
<Skeleton class="h-20 w-full" />
|
||||
</div>
|
||||
{:else if campaignStats}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Total Discount Amount -->
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="text-sm text-gray-500">Total Discount Given</div>
|
||||
<div class="text-2xl font-bold">
|
||||
£{campaignStats.total_discount_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bookings Discounted -->
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="text-sm text-gray-500">Bookings Discounted</div>
|
||||
<div class="text-2xl font-bold">
|
||||
{campaignStats.booking_count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campaign Details -->
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="text-sm font-medium">Campaign Details</div>
|
||||
<div class="mt-2 space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Discount:</span>
|
||||
<span class="font-medium">
|
||||
{campaignStats.campaign.discount_percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Status:</span>
|
||||
<span class="font-medium capitalize">
|
||||
{campaignStats.campaign.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Redeemed:</span>
|
||||
<span class="font-medium">
|
||||
{campaignStats.campaign.times_redeemed}
|
||||
{campaignStats.campaign.max_redemptions
|
||||
? `/${campaignStats.campaign.max_redemptions}`
|
||||
: '/∞'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="py-8 text-center text-gray-500">
|
||||
No stats available for this campaign.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => (showStatsModal = false)}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
{/if}
|
||||
@@ -217,7 +217,8 @@ CREATE TABLE bookings (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by CHAR(12),
|
||||
idempotency_key VARCHAR(64) UNIQUE
|
||||
idempotency_key VARCHAR(64) UNIQUE,
|
||||
discount_eligible BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bookings_userid ON bookings(user_id);
|
||||
@@ -387,6 +388,75 @@ CREATE INDEX idx_payments_bookingid ON payments(booking_id);
|
||||
CREATE INDEX idx_payments_status ON payments(status);
|
||||
CREATE INDEX idx_payments_createdat ON payments(created_at);
|
||||
|
||||
-- =======================================
|
||||
-- LOYALTY REDEMPTIONS TABLE
|
||||
-- =======================================
|
||||
CREATE TABLE loyalty_redemptions (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'),
|
||||
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
stamps_redeemed INT NOT NULL DEFAULT 10,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
|
||||
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
applied_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months')
|
||||
);
|
||||
|
||||
CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id);
|
||||
CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status);
|
||||
|
||||
-- =======================================
|
||||
-- DISCOUNT CAMPAIGNS TABLE
|
||||
-- =======================================
|
||||
CREATE TABLE discount_campaigns (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
campaign_type campaign_type NOT NULL DEFAULT 'time_based',
|
||||
discount_percent NUMERIC(5,2) NOT NULL,
|
||||
scope discount_campaign_scope,
|
||||
start_date TIMESTAMPTZ,
|
||||
end_date TIMESTAMPTZ,
|
||||
milestone_type milestone_type,
|
||||
milestone_value INT,
|
||||
milestone_unit milestone_unit,
|
||||
status discount_campaign_status NOT NULL DEFAULT 'draft',
|
||||
max_redemptions INT,
|
||||
times_redeemed INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)),
|
||||
CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100),
|
||||
CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;
|
||||
CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status);
|
||||
CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type);
|
||||
|
||||
-- =======================================
|
||||
-- BOOKING DISCOUNTS TABLE
|
||||
-- =======================================
|
||||
CREATE TABLE booking_discounts (
|
||||
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'),
|
||||
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
|
||||
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
discount_source VARCHAR(30) NOT NULL,
|
||||
source_id CHAR(12),
|
||||
campaign_type campaign_type,
|
||||
milestone_type milestone_type,
|
||||
discount_percent NUMERIC(5,2) NOT NULL,
|
||||
original_total NUMERIC(10,2) NOT NULL,
|
||||
discount_amount NUMERIC(10,2) NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id);
|
||||
CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id);
|
||||
CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id);
|
||||
CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id);
|
||||
|
||||
-- =======================================
|
||||
-- BUSINESS SETTINGS TABLE (FOR COMPLIANCE)
|
||||
-- Stores legal business info for receipts, VAT status, currency, etc.
|
||||
@@ -420,6 +490,11 @@ CREATE TRIGGER trigger_update_business_settings_timestamp
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_business_settings_timestamp();
|
||||
|
||||
CREATE TRIGGER trigger_update_discount_campaigns_timestamp
|
||||
BEFORE UPDATE ON discount_campaigns
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_business_settings_timestamp();
|
||||
|
||||
-- Insert default row
|
||||
INSERT INTO business_settings (
|
||||
business_name,
|
||||
@@ -445,6 +520,13 @@ INSERT INTO business_settings (
|
||||
|
||||
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request');
|
||||
|
||||
-- Loyalty/Discount System ENUMs
|
||||
CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone');
|
||||
CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary');
|
||||
CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years');
|
||||
CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only');
|
||||
CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled');
|
||||
|
||||
CREATE TABLE admin_notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
reason admin_notification_reason NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user