Skip to content

Commit 3a75e4d

Browse files
committed
ok
1 parent 23ee6ab commit 3a75e4d

15 files changed

Lines changed: 705 additions & 22 deletions

backend/internal/api/coupons.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/waitless/waitless/internal/database"
1010
middleware "github.com/waitless/waitless/internal/middleware"
1111
"github.com/waitless/waitless/internal/models"
12+
"github.com/waitless/waitless/internal/services"
1213
)
1314

1415
// ============ Dashboard Handlers ============
@@ -372,6 +373,12 @@ func APIUpdateCouponStatus(w http.ResponseWriter, r *http.Request) {
372373
"discount_type": coupon.DiscountType,
373374
"discount_value": coupon.DiscountValue,
374375
})
376+
// Telegram notification
377+
go func() {
378+
var sub models.Subscriber
379+
database.DB.Where("id = ?", coupon.SubscriberID).First(&sub)
380+
services.NotifyCouponRedeemed(projectID, coupon.Code, sub.Email)
381+
}()
375382
}
376383

377384
jsonResponse(w, coupon, http.StatusOK)

backend/internal/api/router.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ func NewRouter(version string, startTime time.Time) http.Handler {
130130
r.Delete("/projects/{id}/campaigns/{cid}", DeletePromoCampaign)
131131
r.Get("/projects/{id}/coupons", ListCouponCodes)
132132
r.Patch("/projects/{id}/coupons/{cid}/revoke", RevokeCouponCode)
133+
134+
// Telegram
135+
r.Get("/projects/{id}/telegram", GetTelegramConfig)
136+
r.Put("/projects/{id}/telegram", SaveTelegramConfig)
137+
r.Post("/projects/{id}/telegram/test", TestTelegramConfig)
133138
})
134139

135140
// Admin API

backend/internal/api/subscribers.go

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ import (
1717
)
1818

1919
type signupRequest struct {
20-
Email string `json:"email"`
21-
Name string `json:"name"`
22-
Source string `json:"source"`
23-
Promo string `json:"promo"`
20+
Email string `json:"email"`
21+
Name string `json:"name"`
22+
Source string `json:"source"`
23+
Promo string `json:"promo"`
24+
CustomData map[string]interface{} `json:"custom_data"`
25+
}
26+
27+
type customFieldDef struct {
28+
Key string `json:"key"`
29+
Label string `json:"label"`
30+
Type string `json:"type"` // text, textarea, select, checkbox
31+
Required bool `json:"required"`
32+
Options []string `json:"options,omitempty"`
2433
}
2534

2635
func PublicSignup(w http.ResponseWriter, r *http.Request) {
@@ -74,13 +83,35 @@ func PublicSignup(w http.ResponseWriter, r *http.Request) {
7483
status = models.StatusPending
7584
}
7685

86+
// Validate and store custom data
87+
var customDataJSON string
88+
if project.CustomFields != "" && project.CustomFields != "[]" {
89+
var fields []customFieldDef
90+
if err := json.Unmarshal([]byte(project.CustomFields), &fields); err == nil {
91+
for _, f := range fields {
92+
if f.Required {
93+
val, exists := req.CustomData[f.Key]
94+
if !exists || val == nil || val == "" {
95+
jsonError(w, f.Label+" is required", http.StatusBadRequest)
96+
return
97+
}
98+
}
99+
}
100+
}
101+
if len(req.CustomData) > 0 {
102+
b, _ := json.Marshal(req.CustomData)
103+
customDataJSON = string(b)
104+
}
105+
}
106+
77107
subscriber := models.Subscriber{
78-
ProjectID: project.ID,
79-
Email: req.Email,
80-
Name: req.Name,
81-
Status: status,
82-
Source: source,
83-
IPAddress: strings.Split(r.RemoteAddr, ":")[0],
108+
ProjectID: project.ID,
109+
Email: req.Email,
110+
Name: req.Name,
111+
Status: status,
112+
Source: source,
113+
IPAddress: strings.Split(r.RemoteAddr, ":")[0],
114+
CustomData: customDataJSON,
84115
}
85116

86117
if err := database.DB.Create(&subscriber).Error; err != nil {
@@ -139,6 +170,9 @@ func PublicSignup(w http.ResponseWriter, r *http.Request) {
139170
// Geo-lookup country async
140171
go services.UpdateSubscriberCountry(subscriber.ID, strings.Split(r.RemoteAddr, ":")[0])
141172

173+
// Telegram notification async
174+
go services.NotifyNewSubscriber(project.ID, project.Name, subscriber.Email, subscriber.Name, req.Promo)
175+
142176
resp := map[string]interface{}{
143177
"message": "subscribed",
144178
"subscriber": subscriber,
@@ -174,6 +208,7 @@ func HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
174208

175209
// Fire webhook
176210
go fireWebhooks(subscriber.ProjectID, "subscriber.unsubscribed", subscriber)
211+
go services.NotifyUnsubscribe(subscriber.ProjectID, subscriber.Email)
177212

178213
// Return a simple HTML page
179214
w.Header().Set("Content-Type", "text/html")

backend/internal/api/telegram.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/go-chi/chi/v5"
7+
"github.com/waitless/waitless/internal/database"
8+
middleware "github.com/waitless/waitless/internal/middleware"
9+
"github.com/waitless/waitless/internal/models"
10+
"github.com/waitless/waitless/internal/services"
11+
)
12+
13+
// GetTelegramConfig returns the Telegram notification config for a project
14+
func GetTelegramConfig(w http.ResponseWriter, r *http.Request) {
15+
projectID := chi.URLParam(r, "id")
16+
user := middleware.GetUser(r)
17+
18+
var project models.Project
19+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, user.ID).First(&project).Error; err != nil {
20+
jsonError(w, "project not found", http.StatusNotFound)
21+
return
22+
}
23+
24+
var cfg models.TelegramConfig
25+
database.DB.Where("project_id = ?", projectID).First(&cfg)
26+
jsonResponse(w, cfg, http.StatusOK)
27+
}
28+
29+
// SaveTelegramConfig creates or updates Telegram config
30+
func SaveTelegramConfig(w http.ResponseWriter, r *http.Request) {
31+
projectID := chi.URLParam(r, "id")
32+
user := middleware.GetUser(r)
33+
34+
var project models.Project
35+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, user.ID).First(&project).Error; err != nil {
36+
jsonError(w, "project not found", http.StatusNotFound)
37+
return
38+
}
39+
40+
var input struct {
41+
BotToken string `json:"bot_token"`
42+
ChatID string `json:"chat_id"`
43+
Enabled bool `json:"enabled"`
44+
NotifySignup bool `json:"notify_signup"`
45+
NotifyCoupon bool `json:"notify_coupon"`
46+
NotifyUnsubscribe bool `json:"notify_unsubscribe"`
47+
CampaignFilter string `json:"campaign_filter"`
48+
}
49+
if err := decodeJSON(r, &input); err != nil {
50+
jsonError(w, "invalid request", http.StatusBadRequest)
51+
return
52+
}
53+
54+
var cfg models.TelegramConfig
55+
database.DB.Where("project_id = ?", projectID).First(&cfg)
56+
57+
cfg.ProjectID = projectID
58+
cfg.BotToken = input.BotToken
59+
cfg.ChatID = input.ChatID
60+
cfg.Enabled = input.Enabled
61+
cfg.NotifySignup = input.NotifySignup
62+
cfg.NotifyCoupon = input.NotifyCoupon
63+
cfg.NotifyUnsubscribe = input.NotifyUnsubscribe
64+
cfg.CampaignFilter = input.CampaignFilter
65+
66+
if cfg.ID == "" {
67+
database.DB.Create(&cfg)
68+
} else {
69+
database.DB.Save(&cfg)
70+
}
71+
72+
jsonResponse(w, cfg, http.StatusOK)
73+
}
74+
75+
// TestTelegramConfig sends a test message
76+
func TestTelegramConfig(w http.ResponseWriter, r *http.Request) {
77+
projectID := chi.URLParam(r, "id")
78+
user := middleware.GetUser(r)
79+
80+
var project models.Project
81+
if err := database.DB.Where("id = ? AND user_id = ?", projectID, user.ID).First(&project).Error; err != nil {
82+
jsonError(w, "project not found", http.StatusNotFound)
83+
return
84+
}
85+
86+
var cfg models.TelegramConfig
87+
if err := database.DB.Where("project_id = ?", projectID).First(&cfg).Error; err != nil {
88+
jsonError(w, "telegram not configured", http.StatusBadRequest)
89+
return
90+
}
91+
92+
err := services.SendTelegramNotification(cfg.BotToken, cfg.ChatID,
93+
"✅ <b>Waitless Test</b>\n\nTelegram notifications are working for: "+project.Name)
94+
if err != nil {
95+
jsonError(w, "failed to send: "+err.Error(), http.StatusBadRequest)
96+
return
97+
}
98+
99+
jsonResponse(w, map[string]string{"message": "test sent"}, http.StatusOK)
100+
}

backend/internal/database/db.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func Migrate() error {
4949
&models.Webhook{},
5050
&models.PromoCampaign{},
5151
&models.CouponCode{},
52+
&models.TelegramConfig{},
5253
)
5354
if err != nil {
5455
return fmt.Errorf("migration failed: %w", err)

backend/internal/models/models.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,28 @@ func (wh *Webhook) BeforeCreate(tx *gorm.DB) error {
263263
return nil
264264
}
265265

266+
// TelegramConfig holds per-project Telegram notification settings
267+
type TelegramConfig struct {
268+
ID string `json:"id" gorm:"type:varchar(12);primaryKey"`
269+
ProjectID string `json:"project_id" gorm:"type:varchar(12);not null;index"`
270+
BotToken string `json:"bot_token"`
271+
ChatID string `json:"chat_id"`
272+
Enabled bool `json:"enabled" gorm:"default:false"`
273+
NotifySignup bool `json:"notify_signup" gorm:"default:true"` // new subscriber
274+
NotifyCoupon bool `json:"notify_coupon" gorm:"default:false"` // coupon redeemed
275+
NotifyUnsubscribe bool `json:"notify_unsubscribe" gorm:"default:false"`
276+
CampaignFilter string `json:"campaign_filter"` // optional: only for specific promo codes (comma-separated), empty = all
277+
CreatedAt time.Time `json:"created_at"`
278+
UpdatedAt time.Time `json:"updated_at"`
279+
}
280+
281+
func (t *TelegramConfig) BeforeCreate(tx *gorm.DB) error {
282+
if t.ID == "" {
283+
t.ID = NewID()
284+
}
285+
return nil
286+
}
287+
266288
// CouponStatus tracks the lifecycle of a coupon code
267289
type CouponStatus string
268290

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package services
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
"time"
9+
10+
"github.com/waitless/waitless/internal/database"
11+
"github.com/waitless/waitless/internal/models"
12+
)
13+
14+
var tgClient = &http.Client{Timeout: 5 * time.Second}
15+
16+
// SendTelegramNotification sends a message via Telegram Bot API
17+
func SendTelegramNotification(botToken, chatID, message string) error {
18+
if botToken == "" || chatID == "" {
19+
return nil
20+
}
21+
22+
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
23+
resp, err := tgClient.PostForm(apiURL, url.Values{
24+
"chat_id": {chatID},
25+
"text": {message},
26+
"parse_mode": {"HTML"},
27+
})
28+
if err != nil {
29+
return err
30+
}
31+
defer resp.Body.Close()
32+
33+
if resp.StatusCode != 200 {
34+
return fmt.Errorf("telegram API returned %d", resp.StatusCode)
35+
}
36+
return nil
37+
}
38+
39+
// NotifyNewSubscriber sends a Telegram notification for a new subscriber sign-up
40+
func NotifyNewSubscriber(projectID string, projectName string, email string, name string, promoCode string) {
41+
var cfg models.TelegramConfig
42+
if err := database.DB.Where("project_id = ? AND enabled = true AND notify_signup = true", projectID).
43+
First(&cfg).Error; err != nil {
44+
return
45+
}
46+
47+
// Check campaign filter
48+
if cfg.CampaignFilter != "" && promoCode != "" {
49+
filters := strings.Split(cfg.CampaignFilter, ",")
50+
matched := false
51+
for _, f := range filters {
52+
if strings.TrimSpace(f) == promoCode {
53+
matched = true
54+
break
55+
}
56+
}
57+
if !matched {
58+
return
59+
}
60+
}
61+
62+
msg := fmt.Sprintf("🔔 <b>New Subscriber</b>\n\n📧 %s", email)
63+
if name != "" {
64+
msg += fmt.Sprintf("\n👤 %s", name)
65+
}
66+
msg += fmt.Sprintf("\n📋 %s", projectName)
67+
if promoCode != "" {
68+
msg += fmt.Sprintf("\n🏷 Promo: <code>%s</code>", promoCode)
69+
}
70+
71+
SendTelegramNotification(cfg.BotToken, cfg.ChatID, msg)
72+
}
73+
74+
// NotifyCouponRedeemed sends a Telegram notification when a coupon is redeemed
75+
func NotifyCouponRedeemed(projectID, couponCode, email string) {
76+
var cfg models.TelegramConfig
77+
if err := database.DB.Where("project_id = ? AND enabled = true AND notify_coupon = true", projectID).
78+
First(&cfg).Error; err != nil {
79+
return
80+
}
81+
82+
msg := fmt.Sprintf("🎟 <b>Coupon Redeemed</b>\n\n🔑 <code>%s</code>\n📧 %s", couponCode, email)
83+
SendTelegramNotification(cfg.BotToken, cfg.ChatID, msg)
84+
}
85+
86+
// NotifyUnsubscribe sends a Telegram notification when someone unsubscribes
87+
func NotifyUnsubscribe(projectID, email string) {
88+
var cfg models.TelegramConfig
89+
if err := database.DB.Where("project_id = ? AND enabled = true AND notify_unsubscribe = true", projectID).
90+
First(&cfg).Error; err != nil {
91+
return
92+
}
93+
94+
msg := fmt.Sprintf("👋 <b>Unsubscribed</b>\n\n📧 %s", email)
95+
SendTelegramNotification(cfg.BotToken, cfg.ChatID, msg)
96+
}

0 commit comments

Comments
 (0)