Skip to content

Commit e7a6804

Browse files
authored
feat: investor metrics — velocity, momentum alerts, startup crawl (#8)
* feat: investor metrics — velocity tracking, momentum alerts, startup crawl Backend: - CampaignSnapshot table for 24h velocity calculation - Campaign.velocity_24h + pledge_delta_24h fields - Alert.alert_type ('keyword'|'momentum') + velocity_thresh field - Cron: RunCrawlNow (exported), snapshot storage, velocity computation - ListCampaigns: sort=hot from DB, DB fallback when GraphQL fails - Startup goroutine triggers RunCrawlNow 3s after server boot - Improved Cloudflare bypass headers on session bootstrap iOS: - CampaignDTO: velocity_24h, pledge_delta_24h, first_seen_at fields - CampaignRowView: momentum badge (⚡ +N% / 🔥 +N%) and New badge - DiscoverView: 🔥 Hot sort option - AlertsView: Momentum alert type with velocity threshold slider * feat: add Webshare residential proxy to bypass Cloudflare on Kickstarter requests - Add WEBSHARE_PROXY_URL config read from env/Secrets Manager - Pass proxy to KickstarterGraphClient and KickstarterRESTClient http.Transport - Disable ForceAttemptHTTP2 (incompatible with HTTP CONNECT proxy) - Add webshare-proxy-url to Secrets Manager ARN resolution in deploy workflow - Update .env.example with WEBSHARE_PROXY_URL documentation
1 parent 53f7bf6 commit e7a6804

17 files changed

Lines changed: 329 additions & 67 deletions

File tree

.github/workflows/deploy-backend.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ jobs:
112112
echo "apns_team_id_arn=$(get_arn ${SECRET_PREFIX}/apns-team-id)" >> $GITHUB_OUTPUT
113113
echo "apns_bundle_id_arn=$(get_arn ${SECRET_PREFIX}/apns-bundle-id)" >> $GITHUB_OUTPUT
114114
echo "apns_key_arn=$(get_arn ${SECRET_PREFIX}/apns-key)" >> $GITHUB_OUTPUT
115+
echo "webshare_proxy_url_arn=$(get_arn ${SECRET_PREFIX}/webshare-proxy-url)" >> $GITHUB_OUTPUT
115116
116117
- name: Generate ECS task definition
117118
env:
@@ -141,11 +142,12 @@ jobs:
141142
{ "name": "APNS_ENV", "value": "${{ env.IS_PROD == 'true' && 'production' || 'sandbox' }}" }
142143
],
143144
"secrets": [
144-
{ "name": "DATABASE_URL", "valueFrom": "${{ steps.secrets.outputs.db_arn }}" },
145-
{ "name": "APNS_KEY_ID", "valueFrom": "${{ steps.secrets.outputs.apns_key_id_arn }}" },
146-
{ "name": "APNS_TEAM_ID", "valueFrom": "${{ steps.secrets.outputs.apns_team_id_arn }}" },
147-
{ "name": "APNS_BUNDLE_ID", "valueFrom": "${{ steps.secrets.outputs.apns_bundle_id_arn }}" },
148-
{ "name": "APNS_KEY", "valueFrom": "${{ steps.secrets.outputs.apns_key_arn }}" }
145+
{ "name": "DATABASE_URL", "valueFrom": "${{ steps.secrets.outputs.db_arn }}" },
146+
{ "name": "APNS_KEY_ID", "valueFrom": "${{ steps.secrets.outputs.apns_key_id_arn }}" },
147+
{ "name": "APNS_TEAM_ID", "valueFrom": "${{ steps.secrets.outputs.apns_team_id_arn }}" },
148+
{ "name": "APNS_BUNDLE_ID", "valueFrom": "${{ steps.secrets.outputs.apns_bundle_id_arn }}" },
149+
{ "name": "APNS_KEY", "valueFrom": "${{ steps.secrets.outputs.apns_key_arn }}" },
150+
{ "name": "WEBSHARE_PROXY_URL", "valueFrom": "${{ steps.secrets.outputs.webshare_proxy_url_arn }}" }
149151
],
150152
"readonlyRootFilesystem": true,
151153
"linuxParameters": { "initProcessEnabled": true },

backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
DATABASE_URL=postgres://user:password@localhost:5432/kickwatch?sslmode=disable
22
PORT=8080
33

4+
# Webshare rotating residential proxy - bypasses Cloudflare on server-side Kickstarter requests
5+
# Format: http://username:password@p.webshare.io:80
6+
# Leave empty to disable proxy (direct connection)
7+
WEBSHARE_PROXY_URL=
8+
49
APNS_KEY_ID=YOUR_KEY_ID
510
APNS_TEAM_ID=YOUR_TEAM_ID
611
APNS_BUNDLE_ID=com.yourname.kickwatch

backend/cmd/api/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"log"
5+
"time"
56

67
"github.com/gin-gonic/gin"
78
"github.com/joho/godotenv"
@@ -25,8 +26,8 @@ func main() {
2526
log.Println("DATABASE_URL not set, running without database")
2627
}
2728

28-
graphClient := service.NewKickstarterGraphClient()
29-
restClient := service.NewKickstarterRESTClient()
29+
graphClient := service.NewKickstarterGraphClient(cfg.ProxyURL)
30+
restClient := service.NewKickstarterRESTClient(cfg.ProxyURL)
3031

3132
var cronSvc *service.CronService
3233
if db.IsEnabled() {
@@ -41,6 +42,14 @@ func main() {
4142
cronSvc = service.NewCronService(db.DB, restClient, apnsClient)
4243
cronSvc.Start()
4344
defer cronSvc.Stop()
45+
46+
go func() {
47+
time.Sleep(3 * time.Second)
48+
log.Println("Startup: triggering initial crawl")
49+
if err := cronSvc.RunCrawlNow(); err != nil {
50+
log.Printf("Startup crawl error: %v", err)
51+
}
52+
}()
4453
}
4554

4655
r := gin.Default()

backend/internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Config struct {
1111
APNSKeyPath string
1212
APNSKey string
1313
APNSEnv string
14+
ProxyURL string
1415
}
1516

1617
func Load() *Config {
@@ -31,5 +32,6 @@ func Load() *Config {
3132
APNSKeyPath: os.Getenv("APNS_KEY_PATH"),
3233
APNSKey: os.Getenv("APNS_KEY"),
3334
APNSEnv: apnsEnv,
35+
ProxyURL: os.Getenv("WEBSHARE_PROXY_URL"),
3436
}
3537
}

backend/internal/db/db.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func Init(cfg *config.Config) error {
3737

3838
if err := DB.AutoMigrate(
3939
&model.Campaign{},
40+
&model.CampaignSnapshot{},
4041
&model.Category{},
4142
&model.Device{},
4243
&model.Alert{},

backend/internal/handler/alerts.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import (
1111
)
1212

1313
type createAlertRequest struct {
14-
DeviceID string `json:"device_id" binding:"required"`
15-
Keyword string `json:"keyword" binding:"required"`
16-
CategoryID string `json:"category_id"`
17-
MinPercent float64 `json:"min_percent"`
14+
DeviceID string `json:"device_id" binding:"required"`
15+
AlertType string `json:"alert_type"`
16+
Keyword string `json:"keyword"`
17+
CategoryID string `json:"category_id"`
18+
MinPercent float64 `json:"min_percent"`
19+
VelocityThresh float64 `json:"velocity_thresh"`
1820
}
1921

2022
func CreateAlert(c *gin.Context) {
@@ -24,18 +26,33 @@ func CreateAlert(c *gin.Context) {
2426
return
2527
}
2628

29+
alertType := req.AlertType
30+
if alertType == "" {
31+
alertType = "keyword"
32+
}
33+
if alertType == "keyword" && req.Keyword == "" {
34+
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword is required for keyword alerts"})
35+
return
36+
}
37+
if alertType == "momentum" && req.VelocityThresh <= 0 {
38+
c.JSON(http.StatusBadRequest, gin.H{"error": "velocity_thresh must be > 0 for momentum alerts"})
39+
return
40+
}
41+
2742
deviceID, err := uuid.Parse(req.DeviceID)
2843
if err != nil {
2944
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device_id"})
3045
return
3146
}
3247

3348
alert := model.Alert{
34-
DeviceID: deviceID,
35-
Keyword: req.Keyword,
36-
CategoryID: req.CategoryID,
37-
MinPercent: req.MinPercent,
38-
IsEnabled: true,
49+
DeviceID: deviceID,
50+
AlertType: alertType,
51+
Keyword: req.Keyword,
52+
CategoryID: req.CategoryID,
53+
MinPercent: req.MinPercent,
54+
VelocityThresh: req.VelocityThresh,
55+
IsEnabled: true,
3956
}
4057
if err := db.DB.Create(&alert).Error; err != nil {
4158
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

backend/internal/handler/campaigns.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,45 @@ var sortMap = map[string]string{
1919
func ListCampaigns(graphClient *service.KickstarterGraphClient) gin.HandlerFunc {
2020
return func(c *gin.Context) {
2121
sort := c.DefaultQuery("sort", "trending")
22-
gqlSort, ok := sortMap[sort]
23-
if !ok {
24-
gqlSort = "MAGIC"
25-
}
2622
categoryID := c.Query("category_id")
2723
cursor := c.Query("cursor")
2824
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
2925
if limit > 50 {
3026
limit = 50
3127
}
3228

29+
// "hot" sort: served from DB by velocity_24h
30+
if sort == "hot" && db.IsEnabled() {
31+
var campaigns []model.Campaign
32+
q := db.DB.Where("state = 'live'").Order("velocity_24h DESC").Limit(limit)
33+
if categoryID != "" {
34+
q = q.Where("category_id = ?", categoryID)
35+
}
36+
if err := q.Find(&campaigns).Error; err == nil {
37+
c.JSON(http.StatusOK, gin.H{"campaigns": campaigns, "next_cursor": nil, "total": len(campaigns)})
38+
return
39+
}
40+
}
41+
42+
gqlSort, ok := sortMap[sort]
43+
if !ok {
44+
gqlSort = "MAGIC"
45+
}
46+
3347
result, err := graphClient.Search("", categoryID, gqlSort, cursor, limit)
3448
if err != nil {
49+
// fallback to DB if GraphQL fails
50+
if db.IsEnabled() {
51+
var campaigns []model.Campaign
52+
q := db.DB.Where("state = 'live'").Order("last_updated_at DESC").Limit(limit)
53+
if categoryID != "" {
54+
q = q.Where("category_id = ?", categoryID)
55+
}
56+
if dbErr := q.Find(&campaigns).Error; dbErr == nil && len(campaigns) > 0 {
57+
c.JSON(http.StatusOK, gin.H{"campaigns": campaigns, "next_cursor": nil, "total": len(campaigns)})
58+
return
59+
}
60+
}
3561
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
3662
return
3763
}

backend/internal/model/model.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,27 @@ type Campaign struct {
2323
CreatorName string `json:"creator_name"`
2424
PercentFunded float64 `json:"percent_funded"`
2525
Slug string `json:"slug"`
26+
Velocity24h float64 `gorm:"default:0" json:"velocity_24h"`
27+
PleDelta24h float64 `gorm:"default:0" json:"pledge_delta_24h"`
2628
FirstSeenAt time.Time `gorm:"not null;default:now()" json:"first_seen_at"`
2729
LastUpdatedAt time.Time `gorm:"not null;default:now()" json:"last_updated_at"`
2830
}
2931

32+
type CampaignSnapshot struct {
33+
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
34+
CampaignPID string `gorm:"index;not null" json:"campaign_pid"`
35+
PledgedAmount float64 `json:"pledged_amount"`
36+
PercentFunded float64 `json:"percent_funded"`
37+
SnapshotAt time.Time `gorm:"index;not null;default:now()" json:"snapshot_at"`
38+
}
39+
40+
func (s *CampaignSnapshot) BeforeCreate(tx *gorm.DB) error {
41+
if s.ID == uuid.Nil {
42+
s.ID = uuid.New()
43+
}
44+
return nil
45+
}
46+
3047
type Category struct {
3148
ID string `gorm:"primaryKey" json:"id"`
3249
Name string `gorm:"not null" json:"name"`
@@ -47,20 +64,25 @@ func (d *Device) BeforeCreate(tx *gorm.DB) error {
4764
}
4865

4966
type Alert struct {
50-
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
51-
DeviceID uuid.UUID `gorm:"type:uuid;index;not null" json:"device_id"`
52-
Keyword string `gorm:"not null" json:"keyword"`
53-
CategoryID string `json:"category_id,omitempty"`
54-
MinPercent float64 `gorm:"default:0" json:"min_percent"`
55-
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
56-
CreatedAt time.Time `json:"created_at"`
57-
LastMatchedAt *time.Time `json:"last_matched_at,omitempty"`
67+
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
68+
DeviceID uuid.UUID `gorm:"type:uuid;index;not null" json:"device_id"`
69+
AlertType string `gorm:"not null;default:'keyword'" json:"alert_type"`
70+
Keyword string `json:"keyword"`
71+
CategoryID string `json:"category_id,omitempty"`
72+
MinPercent float64 `gorm:"default:0" json:"min_percent"`
73+
VelocityThresh float64 `gorm:"default:0" json:"velocity_thresh"`
74+
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
75+
CreatedAt time.Time `json:"created_at"`
76+
LastMatchedAt *time.Time `json:"last_matched_at,omitempty"`
5877
}
5978

6079
func (a *Alert) BeforeCreate(tx *gorm.DB) error {
6180
if a.ID == uuid.Nil {
6281
a.ID = uuid.New()
6382
}
83+
if a.AlertType == "" {
84+
a.AlertType = "keyword"
85+
}
6486
return nil
6587
}
6688

0 commit comments

Comments
 (0)