Skip to content

Commit a385916

Browse files
committed
ok
1 parent 57a0e6a commit a385916

13 files changed

Lines changed: 807 additions & 5 deletions

File tree

backend/internal/api/coupons.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"time"
6+
7+
"github.com/go-chi/chi/v5"
8+
"github.com/waitless/waitless/internal/database"
9+
"github.com/waitless/waitless/internal/models"
10+
)
11+
12+
// ============ Dashboard Handlers ============
13+
14+
// GetPromoCampaign returns the promo campaign config for a project
15+
func GetPromoCampaign(w http.ResponseWriter, r *http.Request) {
16+
projectID := chi.URLParam(r, "id")
17+
userID := r.Context().Value("user_id").(string)
18+
19+
// Verify ownership
20+
var project models.Project
21+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil {
22+
jsonError(w, "project not found", http.StatusNotFound)
23+
return
24+
}
25+
26+
var campaign models.PromoCampaign
27+
database.DB.Where("project_id = ?", projectID).First(&campaign)
28+
29+
// Also get stats
30+
var totalCodes int64
31+
var usedCodes int64
32+
var activeCodes int64
33+
database.DB.Model(&models.CouponCode{}).Where("project_id = ?", projectID).Count(&totalCodes)
34+
database.DB.Model(&models.CouponCode{}).Where("project_id = ? AND status = ?", projectID, models.CouponUsed).Count(&usedCodes)
35+
database.DB.Model(&models.CouponCode{}).Where("project_id = ? AND status = ?", projectID, models.CouponActive).Count(&activeCodes)
36+
37+
jsonResponse(w, map[string]interface{}{
38+
"campaign": campaign,
39+
"total_codes": totalCodes,
40+
"used_codes": usedCodes,
41+
"active_codes": activeCodes,
42+
}, http.StatusOK)
43+
}
44+
45+
// SavePromoCampaign creates or updates the promo campaign config
46+
func SavePromoCampaign(w http.ResponseWriter, r *http.Request) {
47+
projectID := chi.URLParam(r, "id")
48+
userID := r.Context().Value("user_id").(string)
49+
50+
var project models.Project
51+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil {
52+
jsonError(w, "project not found", http.StatusNotFound)
53+
return
54+
}
55+
56+
var input struct {
57+
Enabled bool `json:"enabled"`
58+
DiscountType string `json:"discount_type"`
59+
DiscountValue float64 `json:"discount_value"`
60+
Currency string `json:"currency"`
61+
CodePrefix string `json:"code_prefix"`
62+
CodeLength int `json:"code_length"`
63+
MaxCodes int `json:"max_codes"`
64+
ValidDays int `json:"valid_days"`
65+
Description string `json:"description"`
66+
}
67+
if err := decodeJSON(r, &input); err != nil {
68+
jsonError(w, "invalid request", http.StatusBadRequest)
69+
return
70+
}
71+
72+
if input.DiscountType != "flat" && input.DiscountType != "percent" {
73+
input.DiscountType = "flat"
74+
}
75+
if input.CodeLength < 6 {
76+
input.CodeLength = 8
77+
}
78+
if input.Currency == "" {
79+
input.Currency = "USD"
80+
}
81+
82+
var campaign models.PromoCampaign
83+
database.DB.Where("project_id = ?", projectID).First(&campaign)
84+
85+
campaign.ProjectID = projectID
86+
campaign.Enabled = input.Enabled
87+
campaign.DiscountType = models.DiscountType(input.DiscountType)
88+
campaign.DiscountValue = input.DiscountValue
89+
campaign.Currency = input.Currency
90+
campaign.CodePrefix = input.CodePrefix
91+
campaign.CodeLength = input.CodeLength
92+
campaign.MaxCodes = input.MaxCodes
93+
campaign.ValidDays = input.ValidDays
94+
campaign.Description = input.Description
95+
96+
if campaign.ID == "" {
97+
database.DB.Create(&campaign)
98+
} else {
99+
database.DB.Save(&campaign)
100+
}
101+
102+
jsonResponse(w, campaign, http.StatusOK)
103+
}
104+
105+
// ListCouponCodes lists all coupon codes for a project
106+
func ListCouponCodes(w http.ResponseWriter, r *http.Request) {
107+
projectID := chi.URLParam(r, "id")
108+
userID := r.Context().Value("user_id").(string)
109+
110+
var project models.Project
111+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil {
112+
jsonError(w, "project not found", http.StatusNotFound)
113+
return
114+
}
115+
116+
status := r.URL.Query().Get("status")
117+
search := r.URL.Query().Get("search")
118+
page := queryInt(r, "page", 1)
119+
limit := queryInt(r, "limit", 50)
120+
offset := (page - 1) * limit
121+
122+
query := database.DB.Where("coupon_codes.project_id = ?", projectID).
123+
Preload("Subscriber")
124+
125+
if status != "" {
126+
query = query.Where("coupon_codes.status = ?", status)
127+
}
128+
if search != "" {
129+
query = query.Joins("JOIN subscribers ON subscribers.id = coupon_codes.subscriber_id").
130+
Where("coupon_codes.code ILIKE ? OR subscribers.email ILIKE ?", "%"+search+"%", "%"+search+"%")
131+
}
132+
133+
var total int64
134+
query.Model(&models.CouponCode{}).Count(&total)
135+
136+
var codes []models.CouponCode
137+
query.Order("coupon_codes.created_at DESC").Offset(offset).Limit(limit).Find(&codes)
138+
139+
// Auto-expire codes past their expiry
140+
now := time.Now()
141+
for i, c := range codes {
142+
if c.Status == models.CouponActive && c.ExpiresAt != nil && c.ExpiresAt.Before(now) {
143+
codes[i].Status = models.CouponExpired
144+
database.DB.Model(&c).Update("status", models.CouponExpired)
145+
}
146+
}
147+
148+
jsonResponse(w, map[string]interface{}{
149+
"coupons": codes,
150+
"total": total,
151+
"page": page,
152+
}, http.StatusOK)
153+
}
154+
155+
// RevokeCouponCode revokes a single coupon code
156+
func RevokeCouponCode(w http.ResponseWriter, r *http.Request) {
157+
projectID := chi.URLParam(r, "id")
158+
couponID := chi.URLParam(r, "cid")
159+
userID := r.Context().Value("user_id").(string)
160+
161+
var project models.Project
162+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil {
163+
jsonError(w, "project not found", http.StatusNotFound)
164+
return
165+
}
166+
167+
var coupon models.CouponCode
168+
if err := database.DB.Where("id = ? AND project_id = ?", couponID, projectID).First(&coupon).Error; err != nil {
169+
jsonError(w, "coupon not found", http.StatusNotFound)
170+
return
171+
}
172+
173+
coupon.Status = models.CouponRevoked
174+
database.DB.Save(&coupon)
175+
176+
jsonResponse(w, coupon, http.StatusOK)
177+
}
178+
179+
// ============ REST API v1 Handlers ============
180+
181+
// APIValidateCoupon validates a coupon code and returns discount info
182+
func APIValidateCoupon(w http.ResponseWriter, r *http.Request) {
183+
projectID := r.Context().Value("project_id").(string)
184+
code := r.URL.Query().Get("code")
185+
186+
if code == "" {
187+
jsonError(w, "code parameter required", http.StatusBadRequest)
188+
return
189+
}
190+
191+
var coupon models.CouponCode
192+
if err := database.DB.Where("code = ? AND project_id = ?", code, projectID).
193+
Preload("Subscriber").First(&coupon).Error; err != nil {
194+
jsonError(w, "coupon not found", http.StatusNotFound)
195+
return
196+
}
197+
198+
// Auto-expire if past expiry
199+
if coupon.Status == models.CouponActive && coupon.ExpiresAt != nil && coupon.ExpiresAt.Before(time.Now()) {
200+
coupon.Status = models.CouponExpired
201+
database.DB.Model(&coupon).Update("status", models.CouponExpired)
202+
}
203+
204+
valid := coupon.Status == models.CouponActive
205+
206+
jsonResponse(w, map[string]interface{}{
207+
"valid": valid,
208+
"code": coupon.Code,
209+
"status": coupon.Status,
210+
"discount_type": coupon.DiscountType,
211+
"discount_value": coupon.DiscountValue,
212+
"currency": coupon.Currency,
213+
"expires_at": coupon.ExpiresAt,
214+
"subscriber": map[string]interface{}{
215+
"id": coupon.Subscriber.ID,
216+
"email": coupon.Subscriber.Email,
217+
"name": coupon.Subscriber.Name,
218+
},
219+
}, http.StatusOK)
220+
}
221+
222+
// APIUpdateCouponStatus updates the status of a coupon code via REST API
223+
func APIUpdateCouponStatus(w http.ResponseWriter, r *http.Request) {
224+
projectID := r.Context().Value("project_id").(string)
225+
code := chi.URLParam(r, "code")
226+
227+
var input struct {
228+
Status string `json:"status"`
229+
}
230+
if err := decodeJSON(r, &input); err != nil {
231+
jsonError(w, "invalid request", http.StatusBadRequest)
232+
return
233+
}
234+
235+
validStatuses := map[string]bool{"active": true, "used": true, "revoked": true, "expired": true}
236+
if !validStatuses[input.Status] {
237+
jsonError(w, "invalid status, must be: active, used, revoked, expired", http.StatusBadRequest)
238+
return
239+
}
240+
241+
var coupon models.CouponCode
242+
if err := database.DB.Where("code = ? AND project_id = ?", code, projectID).First(&coupon).Error; err != nil {
243+
jsonError(w, "coupon not found", http.StatusNotFound)
244+
return
245+
}
246+
247+
coupon.Status = models.CouponStatus(input.Status)
248+
if input.Status == "used" {
249+
now := time.Now()
250+
coupon.UsedAt = &now
251+
}
252+
database.DB.Save(&coupon)
253+
254+
// Fire webhook if status changed to "used"
255+
if input.Status == "used" {
256+
go fireWebhooks(projectID, "coupon.redeemed", map[string]interface{}{
257+
"coupon_code": coupon.Code,
258+
"subscriber_id": coupon.SubscriberID,
259+
"discount_type": coupon.DiscountType,
260+
"discount_value": coupon.DiscountValue,
261+
})
262+
}
263+
264+
jsonResponse(w, coupon, http.StatusOK)
265+
}
266+
267+
// GenerateCouponForSubscriber creates a coupon code for a new subscriber if promo is enabled
268+
func GenerateCouponForSubscriber(projectID, subscriberID string) *models.CouponCode {
269+
var campaign models.PromoCampaign
270+
if err := database.DB.Where("project_id = ? AND enabled = true", projectID).First(&campaign).Error; err != nil {
271+
return nil
272+
}
273+
274+
// Check max codes limit
275+
if campaign.MaxCodes > 0 {
276+
var count int64
277+
database.DB.Model(&models.CouponCode{}).Where("project_id = ?", projectID).Count(&count)
278+
if int(count) >= campaign.MaxCodes {
279+
return nil
280+
}
281+
}
282+
283+
code := models.GenerateCouponCode(campaign.CodePrefix, campaign.CodeLength)
284+
285+
coupon := models.CouponCode{
286+
ProjectID: projectID,
287+
SubscriberID: subscriberID,
288+
Code: code,
289+
Status: models.CouponActive,
290+
DiscountType: campaign.DiscountType,
291+
DiscountValue: campaign.DiscountValue,
292+
Currency: campaign.Currency,
293+
}
294+
295+
if campaign.ValidDays > 0 {
296+
exp := time.Now().AddDate(0, 0, campaign.ValidDays)
297+
coupon.ExpiresAt = &exp
298+
}
299+
300+
database.DB.Create(&coupon)
301+
return &coupon
302+
}

backend/internal/api/helpers.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"encoding/json"
55
"net/http"
6+
"strconv"
67
)
78

89
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
@@ -16,3 +17,19 @@ func jsonError(w http.ResponseWriter, msg string, status int) {
1617
w.WriteHeader(status)
1718
json.NewEncoder(w).Encode(map[string]string{"error": msg})
1819
}
20+
21+
func decodeJSON(r *http.Request, v interface{}) error {
22+
return json.NewDecoder(r.Body).Decode(v)
23+
}
24+
25+
func queryInt(r *http.Request, key string, fallback int) int {
26+
s := r.URL.Query().Get(key)
27+
if s == "" {
28+
return fallback
29+
}
30+
n, err := strconv.Atoi(s)
31+
if err != nil {
32+
return fallback
33+
}
34+
return n
35+
}

backend/internal/api/router.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ func NewRouter(version string, startTime time.Time) http.Handler {
122122

123123
// Widget
124124
r.Get("/projects/{id}/widget", GetWidgetCode)
125+
126+
// Coupons / Promo
127+
r.Get("/projects/{id}/promo", GetPromoCampaign)
128+
r.Put("/projects/{id}/promo", SavePromoCampaign)
129+
r.Get("/projects/{id}/coupons", ListCouponCodes)
130+
r.Patch("/projects/{id}/coupons/{cid}/revoke", RevokeCouponCode)
125131
})
126132

127133
// Admin API
@@ -141,6 +147,8 @@ func NewRouter(version string, startTime time.Time) http.Handler {
141147
r.Get("/projects/{projectId}/subscribers", APIListSubscribers)
142148
r.Post("/projects/{projectId}/subscribers", APIAddSubscriber)
143149
r.Get("/projects/{projectId}/count", APISubscriberCount)
150+
r.Get("/projects/{projectId}/coupons/validate", APIValidateCoupon)
151+
r.Patch("/projects/{projectId}/coupons/{code}/status", APIUpdateCouponStatus)
144152
})
145153

146154
return r

backend/internal/api/subscribers.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,24 @@ func PublicSignup(w http.ResponseWriter, r *http.Request) {
129129
// Fire webhooks async
130130
go fireWebhooks(project.ID, "subscriber.created", subscriber)
131131

132-
jsonResponse(w, map[string]interface{}{
132+
// Generate coupon code if promo campaign is enabled
133+
coupon := GenerateCouponForSubscriber(project.ID, subscriber.ID)
134+
135+
resp := map[string]interface{}{
133136
"message": "subscribed",
134137
"subscriber": subscriber,
135-
}, http.StatusCreated)
138+
}
139+
if coupon != nil {
140+
resp["coupon"] = map[string]interface{}{
141+
"code": coupon.Code,
142+
"discount_type": coupon.DiscountType,
143+
"discount_value": coupon.DiscountValue,
144+
"currency": coupon.Currency,
145+
"expires_at": coupon.ExpiresAt,
146+
}
147+
}
148+
149+
jsonResponse(w, resp, http.StatusCreated)
136150
}
137151

138152
func HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {

backend/internal/database/db.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ func Migrate() error {
4747
&models.EmailLog{},
4848
&models.AnalyticsRecord{},
4949
&models.Webhook{},
50+
&models.PromoCampaign{},
51+
&models.CouponCode{},
5052
)
5153
if err != nil {
5254
return fmt.Errorf("migration failed: %w", err)

0 commit comments

Comments
 (0)