From ee84871426cf2ef49654eaf82250165a1a2f8048 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:25:44 +0800 Subject: [PATCH 01/33] feat(backend): init Go module and main.go entry point --- backend/cmd/api/main.go | 75 +++++++++++++++++++++++++++++ backend/go.mod | 50 +++++++++++++++++++ backend/go.sum | 104 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 backend/cmd/api/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..42dc886 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + "github.com/kickwatch/backend/internal/config" + "github.com/kickwatch/backend/internal/db" + "github.com/kickwatch/backend/internal/handler" + "github.com/kickwatch/backend/internal/middleware" + "github.com/kickwatch/backend/internal/service" +) + +func main() { + _ = godotenv.Load() + + cfg := config.Load() + + if cfg.DatabaseURL != "" { + if err := db.Init(cfg); err != nil { + log.Fatalf("DB init: %v", err) + } + } else { + log.Println("DATABASE_URL not set, running without database") + } + + graphClient := service.NewKickstarterGraphClient() + restClient := service.NewKickstarterRESTClient() + + var cronSvc *service.CronService + if db.IsEnabled() { + var apnsClient *service.APNsClient + if cfg.APNSKeyPath != "" { + var err error + apnsClient, err = service.NewAPNsClient(cfg) + if err != nil { + log.Printf("APNs init failed (push disabled): %v", err) + } + } + cronSvc = service.NewCronService(db.DB, restClient, apnsClient) + cronSvc.Start() + defer cronSvc.Stop() + } + + r := gin.Default() + r.Use(middleware.CORS()) + r.Use(middleware.Logger()) + + api := r.Group("/api") + { + api.GET("/health", handler.Health) + + api.GET("/campaigns", handler.ListCampaigns(graphClient)) + api.GET("/campaigns/search", handler.SearchCampaigns(graphClient)) + api.GET("/campaigns/:pid", handler.GetCampaign) + api.GET("/categories", handler.ListCategories(graphClient)) + + api.POST("/devices/register", handler.RegisterDevice) + + alerts := api.Group("/alerts") + { + alerts.POST("", handler.CreateAlert) + alerts.GET("", handler.ListAlerts) + alerts.PATCH("/:id", handler.UpdateAlert) + alerts.DELETE("/:id", handler.DeleteAlert) + alerts.GET("/:id/matches", handler.GetAlertMatches) + } + } + + log.Printf("KickWatch API starting on :%s", cfg.Port) + if err := r.Run(":" + cfg.Port); err != nil { + log.Fatal(err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..513f5da --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,50 @@ +module github.com/kickwatch/backend + +go 1.25.5 + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.11.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..717a086 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,104 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= From a21fda91f8d89b926dbb209a44ad89bdad9b5921 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:25:48 +0800 Subject: [PATCH 02/33] feat(backend): add config, GORM models, and DB connection --- backend/internal/config/config.go | 33 +++++++++++++ backend/internal/db/db.go | 54 +++++++++++++++++++++ backend/internal/model/model.go | 79 +++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/db/db.go create mode 100644 backend/internal/model/model.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..870437d --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,33 @@ +package config + +import "os" + +type Config struct { + DatabaseURL string + Port string + APNSKeyID string + APNSTeamID string + APNSBundleID string + APNSKeyPath string + APNSEnv string +} + +func Load() *Config { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + apnsEnv := os.Getenv("APNS_ENV") + if apnsEnv == "" { + apnsEnv = "sandbox" + } + return &Config{ + DatabaseURL: os.Getenv("DATABASE_URL"), + Port: port, + APNSKeyID: os.Getenv("APNS_KEY_ID"), + APNSTeamID: os.Getenv("APNS_TEAM_ID"), + APNSBundleID: os.Getenv("APNS_BUNDLE_ID"), + APNSKeyPath: os.Getenv("APNS_KEY_PATH"), + APNSEnv: apnsEnv, + } +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..8877ab9 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,54 @@ +package db + +import ( + "fmt" + "log" + "time" + + "github.com/kickwatch/backend/internal/config" + "github.com/kickwatch/backend/internal/model" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Init(cfg *config.Config) error { + if cfg.DatabaseURL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + + var err error + DB, err = gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return fmt.Errorf("connect db: %w", err) + } + + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("get sql.DB: %w", err) + } + sqlDB.SetMaxIdleConns(5) + sqlDB.SetMaxOpenConns(20) + sqlDB.SetConnMaxLifetime(time.Hour) + + if err := DB.AutoMigrate( + &model.Campaign{}, + &model.Category{}, + &model.Device{}, + &model.Alert{}, + &model.AlertMatch{}, + ); err != nil { + return fmt.Errorf("migrate: %w", err) + } + + log.Println("Database connected and migrated") + return nil +} + +func IsEnabled() bool { + return DB != nil +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go new file mode 100644 index 0000000..a44e4d1 --- /dev/null +++ b/backend/internal/model/model.go @@ -0,0 +1,79 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Campaign struct { + PID string `gorm:"primaryKey" json:"pid"` + Name string `gorm:"not null" json:"name"` + Blurb string `json:"blurb"` + PhotoURL string `json:"photo_url"` + GoalAmount float64 `json:"goal_amount"` + GoalCurrency string `json:"goal_currency"` + PledgedAmount float64 `json:"pledged_amount"` + Deadline time.Time `json:"deadline"` + State string `json:"state"` + CategoryID string `json:"category_id"` + CategoryName string `json:"category_name"` + ProjectURL string `json:"project_url"` + CreatorName string `json:"creator_name"` + PercentFunded float64 `json:"percent_funded"` + Slug string `json:"slug"` + FirstSeenAt time.Time `gorm:"not null;default:now()" json:"first_seen_at"` + LastUpdatedAt time.Time `gorm:"not null;default:now()" json:"last_updated_at"` +} + +type Category struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + ParentID string `json:"parent_id,omitempty"` +} + +type Device struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + DeviceToken string `gorm:"uniqueIndex;not null" json:"device_token"` + CreatedAt time.Time `json:"created_at"` +} + +func (d *Device) BeforeCreate(tx *gorm.DB) error { + if d.ID == uuid.Nil { + d.ID = uuid.New() + } + return nil +} + +type Alert struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + DeviceID uuid.UUID `gorm:"type:uuid;index;not null" json:"device_id"` + Keyword string `gorm:"not null" json:"keyword"` + CategoryID string `json:"category_id,omitempty"` + MinPercent float64 `gorm:"default:0" json:"min_percent"` + IsEnabled bool `gorm:"default:true" json:"is_enabled"` + CreatedAt time.Time `json:"created_at"` + LastMatchedAt *time.Time `json:"last_matched_at,omitempty"` +} + +func (a *Alert) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + return nil +} + +type AlertMatch struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + AlertID uuid.UUID `gorm:"type:uuid;index;not null" json:"alert_id"` + CampaignPID string `json:"campaign_pid"` + MatchedAt time.Time `gorm:"default:now()" json:"matched_at"` +} + +func (am *AlertMatch) BeforeCreate(tx *gorm.DB) error { + if am.ID == uuid.Nil { + am.ID = uuid.New() + } + return nil +} From 22e07cf5c12e94b668c1af43c68abd895648e7ad Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:25:53 +0800 Subject: [PATCH 03/33] feat(backend): add CORS and request logger middleware --- backend/internal/middleware/cors.go | 16 ++++++++++++++++ backend/internal/middleware/logger.go | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 backend/internal/middleware/cors.go create mode 100644 backend/internal/middleware/logger.go diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..af2b3d8 --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,16 @@ +package middleware + +import "github.com/gin-gonic/gin" + +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + } +} diff --git a/backend/internal/middleware/logger.go b/backend/internal/middleware/logger.go new file mode 100644 index 0000000..8afa2d9 --- /dev/null +++ b/backend/internal/middleware/logger.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "log" + "time" + + "github.com/gin-gonic/gin" +) + +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(start)) + } +} From 0ed4c5be87e69c15ee5bc30cef75334c31874a09 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:25:58 +0800 Subject: [PATCH 04/33] feat(backend): add Kickstarter REST discover client --- backend/internal/service/kickstarter_rest.go | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 backend/internal/service/kickstarter_rest.go diff --git a/backend/internal/service/kickstarter_rest.go b/backend/internal/service/kickstarter_rest.go new file mode 100644 index 0000000..50ab211 --- /dev/null +++ b/backend/internal/service/kickstarter_rest.go @@ -0,0 +1,117 @@ +package service + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/kickwatch/backend/internal/model" +) + +const restBaseURL = "https://www.kickstarter.com/discover/advanced.json" + +type restProject struct { + ID int64 `json:"id"` + Name string `json:"name"` + Blurb string `json:"blurb"` + State string `json:"state"` + PercentFunded int `json:"percent_funded"` + Goal string `json:"goal"` + Pledged string `json:"pledged"` + Currency string `json:"currency"` + Deadline int64 `json:"deadline"` + URL string `json:"urls"` + Slug string `json:"slug"` + Photo struct { + Full string `json:"full"` + } `json:"photo"` + Creator struct { + Name string `json:"name"` + } `json:"creator"` + Category struct { + ID int `json:"id"` + Name string `json:"name"` + ParentID *int `json:"parent_id"` + } `json:"category"` + URLs struct { + Web struct { + Project string `json:"project"` + } `json:"web"` + } `json:"urls"` +} + +type restResponse struct { + Projects []restProject `json:"projects"` +} + +type KickstarterRESTClient struct { + httpClient *http.Client +} + +func NewKickstarterRESTClient() *KickstarterRESTClient { + return &KickstarterRESTClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *KickstarterRESTClient) DiscoverCampaigns(categoryID string, sort string, page int) ([]model.Campaign, error) { + params := url.Values{} + params.Set("sort", sort) + params.Set("page", strconv.Itoa(page)) + params.Set("per_page", "20") + if categoryID != "" { + params.Set("category_id", categoryID) + } + + reqURL := restBaseURL + "?" + params.Encode() + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("rest discover: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("rest discover: status %d", resp.StatusCode) + } + + var result restResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("rest decode: %w", err) + } + + campaigns := make([]model.Campaign, 0, len(result.Projects)) + for _, p := range result.Projects { + goal, _ := strconv.ParseFloat(p.Goal, 64) + pledged, _ := strconv.ParseFloat(p.Pledged, 64) + deadline := time.Unix(p.Deadline, 0) + + campaigns = append(campaigns, model.Campaign{ + PID: strconv.FormatInt(p.ID, 10), + Name: p.Name, + Blurb: p.Blurb, + PhotoURL: p.Photo.Full, + GoalAmount: goal, + GoalCurrency: p.Currency, + PledgedAmount: pledged, + Deadline: deadline, + State: p.State, + CategoryID: strconv.Itoa(p.Category.ID), + CategoryName: p.Category.Name, + ProjectURL: p.URLs.Web.Project, + CreatorName: p.Creator.Name, + PercentFunded: float64(p.PercentFunded), + Slug: p.Slug, + }) + } + return campaigns, nil +} From b10a9a940918488cdcd8cdd271e52d9e3c4469a9 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:26:02 +0800 Subject: [PATCH 05/33] feat(backend): add Kickstarter GraphQL client with session bootstrap --- backend/internal/service/kickstarter_graph.go | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 backend/internal/service/kickstarter_graph.go diff --git a/backend/internal/service/kickstarter_graph.go b/backend/internal/service/kickstarter_graph.go new file mode 100644 index 0000000..befbac5 --- /dev/null +++ b/backend/internal/service/kickstarter_graph.go @@ -0,0 +1,293 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strconv" + "sync" + "time" + + "github.com/kickwatch/backend/internal/model" +) + +const ( + ksBaseURL = "https://www.kickstarter.com" + ksGraphURL = "https://www.kickstarter.com/graph" + sessionTTL = 12 * time.Hour +) + +var csrfPattern = regexp.MustCompile(`]+name="csrf-token"[^>]+content="([^"]+)"`) + +type graphSession struct { + cookie string + csrfToken string + fetchedAt time.Time +} + +type KickstarterGraphClient struct { + mu sync.Mutex + session *graphSession + httpClient *http.Client +} + +func NewKickstarterGraphClient() *KickstarterGraphClient { + return &KickstarterGraphClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *KickstarterGraphClient) ensureSession() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.session != nil && time.Since(c.session.fetchedAt) < sessionTTL { + return nil + } + + req, _ := http.NewRequest("GET", ksBaseURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + req.Header.Set("Accept", "text/html") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("bootstrap session: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read bootstrap body: %w", err) + } + + matches := csrfPattern.FindSubmatch(body) + if len(matches) < 2 { + return fmt.Errorf("csrf token not found in page") + } + csrfToken := string(matches[1]) + + var sessionCookie string + for _, cookie := range resp.Cookies() { + if cookie.Name == "_ksr_session" { + sessionCookie = cookie.Value + break + } + } + if sessionCookie == "" { + return fmt.Errorf("_ksr_session cookie not found") + } + + c.session = &graphSession{ + cookie: sessionCookie, + csrfToken: csrfToken, + fetchedAt: time.Now(), + } + log.Println("Kickstarter GraphQL session refreshed") + return nil +} + +func (c *KickstarterGraphClient) doGraphQL(query string, variables map[string]interface{}, result interface{}) error { + if err := c.ensureSession(); err != nil { + return err + } + + payload := map[string]interface{}{ + "query": query, + "variables": variables, + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequest("POST", ksGraphURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + req.Header.Set("x-csrf-token", c.session.csrfToken) + req.Header.Set("Cookie", "_ksr_session="+c.session.cookie) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("graphql request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden { + c.mu.Lock() + c.session = nil + c.mu.Unlock() + return fmt.Errorf("graphql 403: session expired") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("graphql status %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(result) +} + +const searchQuery = ` +query Search($term: String, $sort: ProjectSort, $categoryId: String, $state: PublicProjectState, $first: Int, $cursor: String) { + projects(term: $term, sort: $sort, categoryId: $categoryId, state: $state, after: $cursor, first: $first) { + nodes { + pid + name + state + deadlineAt + percentFunded + url + image { url(width: 1024) } + goal { amount currency } + pledged { amount currency } + creator { name } + category { id name } + } + totalCount + pageInfo { endCursor hasNextPage } + } +}` + +type graphSearchResp struct { + Data struct { + Projects struct { + Nodes []struct { + PID string `json:"pid"` + Name string `json:"name"` + State string `json:"state"` + DeadlineAt *string `json:"deadlineAt"` + PercentFunded float64 `json:"percentFunded"` + URL string `json:"url"` + Image *struct { + URL string `json:"url"` + } `json:"image"` + Goal *struct { + Amount string + Currency string + } `json:"goal"` + Pledged *struct { + Amount string + Currency string + } `json:"pledged"` + Creator *struct{ Name string } `json:"creator"` + Category *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"category"` + } `json:"nodes"` + TotalCount int `json:"totalCount"` + PageInfo struct { + EndCursor string `json:"endCursor"` + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + } `json:"projects"` + } `json:"data"` +} + +type SearchResult struct { + Campaigns []model.Campaign + TotalCount int + NextCursor string + HasNextPage bool +} + +func (c *KickstarterGraphClient) Search(term, categoryID, sort, cursor string, first int) (*SearchResult, error) { + vars := map[string]interface{}{ + "term": term, + "sort": sort, + "first": first, + "state": "LIVE", + } + if categoryID != "" { + vars["categoryId"] = categoryID + } + if cursor != "" { + vars["cursor"] = cursor + } + + var resp graphSearchResp + if err := c.doGraphQL(searchQuery, vars, &resp); err != nil { + return nil, err + } + + campaigns := make([]model.Campaign, 0, len(resp.Data.Projects.Nodes)) + for _, n := range resp.Data.Projects.Nodes { + cam := model.Campaign{ + PID: n.PID, + Name: n.Name, + State: n.State, + ProjectURL: n.URL, + } + if n.Image != nil { + cam.PhotoURL = n.Image.URL + } + if n.Goal != nil { + cam.GoalAmount, _ = strconv.ParseFloat(n.Goal.Amount, 64) + cam.GoalCurrency = n.Goal.Currency + } + if n.Pledged != nil { + cam.PledgedAmount, _ = strconv.ParseFloat(n.Pledged.Amount, 64) + } + if n.Creator != nil { + cam.CreatorName = n.Creator.Name + } + if n.Category != nil { + cam.CategoryID = n.Category.ID + cam.CategoryName = n.Category.Name + } + if n.DeadlineAt != nil { + cam.Deadline, _ = time.Parse(time.RFC3339, *n.DeadlineAt) + } + cam.PercentFunded = n.PercentFunded + campaigns = append(campaigns, cam) + } + + return &SearchResult{ + Campaigns: campaigns, + TotalCount: resp.Data.Projects.TotalCount, + NextCursor: resp.Data.Projects.PageInfo.EndCursor, + HasNextPage: resp.Data.Projects.PageInfo.HasNextPage, + }, nil +} + +const categoriesQuery = ` +query FetchRootCategories { + rootCategories { + id + name + subcategories { + nodes { id name parentId } + } + } +}` + +type graphCategoriesResp struct { + Data struct { + RootCategories []struct { + ID string `json:"id"` + Name string `json:"name"` + Subcategories struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID string `json:"parentId"` + } `json:"nodes"` + } `json:"subcategories"` + } `json:"rootCategories"` + } `json:"data"` +} + +func (c *KickstarterGraphClient) FetchCategories() ([]model.Category, error) { + var resp graphCategoriesResp + if err := c.doGraphQL(categoriesQuery, nil, &resp); err != nil { + return nil, err + } + + var cats []model.Category + for _, rc := range resp.Data.RootCategories { + cats = append(cats, model.Category{ID: rc.ID, Name: rc.Name}) + for _, sub := range rc.Subcategories.Nodes { + cats = append(cats, model.Category{ID: sub.ID, Name: sub.Name, ParentID: sub.ParentID}) + } + } + return cats, nil +} From ac518a07e60dc5ae2653b578c6c00c8e0f65f871 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:26:05 +0800 Subject: [PATCH 06/33] feat(backend): add APNs HTTP/2 push notification service --- backend/internal/service/apns.go | 164 +++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 backend/internal/service/apns.go diff --git a/backend/internal/service/apns.go b/backend/internal/service/apns.go new file mode 100644 index 0000000..68a8c7f --- /dev/null +++ b/backend/internal/service/apns.go @@ -0,0 +1,164 @@ +package service + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/kickwatch/backend/internal/config" +) + +type APNsClient struct { + cfg *config.Config + httpClient *http.Client + mu sync.Mutex + token string + tokenExpAt time.Time + privKey *ecdsa.PrivateKey +} + +func NewAPNsClient(cfg *config.Config) (*APNsClient, error) { + keyData, err := os.ReadFile(cfg.APNSKeyPath) + if err != nil { + return nil, fmt.Errorf("read apns key: %w", err) + } + block, _ := pem.Decode(keyData) + if block == nil { + return nil, fmt.Errorf("invalid pem block in apns key") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse apns key: %w", err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("apns key is not ECDSA") + } + + transport := &http.Transport{} + _ = http.ProxyFromEnvironment + + return &APNsClient{ + cfg: cfg, + httpClient: &http.Client{Transport: transport, Timeout: 10 * time.Second}, + privKey: ecKey, + }, nil +} + +func (a *APNsClient) bearerToken() (string, error) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.token != "" && time.Now().Before(a.tokenExpAt) { + return a.token, nil + } + + now := time.Now() + claims := jwt.RegisteredClaims{ + Issuer: a.cfg.APNSTeamID, + IssuedAt: jwt.NewNumericDate(now), + } + t := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + t.Header["kid"] = a.cfg.APNSKeyID + + signed, err := t.SignedString(a.privKey) + if err != nil { + return "", fmt.Errorf("sign apns jwt: %w", err) + } + a.token = signed + a.tokenExpAt = now.Add(45 * time.Minute) + return signed, nil +} + +type APNsPayload struct { + APS struct { + Alert struct { + Title string `json:"title"` + Body string `json:"body"` + } `json:"alert"` + Badge int `json:"badge,omitempty"` + Sound string `json:"sound,omitempty"` + } `json:"aps"` + AlertID string `json:"alert_id,omitempty"` + MatchCount int `json:"match_count,omitempty"` +} + +func (a *APNsClient) Send(deviceToken string, payload APNsPayload) error { + host := "https://api.push.apple.com" + if a.cfg.APNSEnv == "sandbox" { + host = "https://api.sandbox.push.apple.com" + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + url := fmt.Sprintf("%s/3/device/%s", host, deviceToken) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + + token, err := a.bearerToken() + if err != nil { + return err + } + + req.Header.Set("Authorization", "bearer "+token) + req.Header.Set("apns-topic", a.cfg.APNSBundleID) + req.Header.Set("apns-push-type", "alert") + req.Header.Set("Content-Type", "application/json") + req.Body = http.NoBody + req.ContentLength = int64(len(body)) + + // Re-set body after NoBody assignment + req2, _ := http.NewRequest("POST", url, jsonBody(body)) + req2.Header = req.Header.Clone() + + resp, err := a.httpClient.Do(req2) + if err != nil { + return fmt.Errorf("apns send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusGone { + return fmt.Errorf("apns: device token invalid (410)") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("apns status %d", resp.StatusCode) + } + log.Printf("APNs sent to %s...", deviceToken[:min(8, len(deviceToken))]) + return nil +} + +type byteReader struct { + data []byte + offset int +} + +func jsonBody(data []byte) *byteReader { return &byteReader{data: data} } + +func (r *byteReader) Read(p []byte) (int, error) { + if r.offset >= len(r.data) { + return 0, fmt.Errorf("EOF") + } + n := copy(p, r.data[r.offset:]) + r.offset += n + return n, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} From c285225ed519a86799124f1113fc422e75b20b15 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:26:11 +0800 Subject: [PATCH 07/33] feat(backend): add nightly cron crawler and alert matching --- backend/internal/service/cron.go | 152 +++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 backend/internal/service/cron.go diff --git a/backend/internal/service/cron.go b/backend/internal/service/cron.go new file mode 100644 index 0000000..10581c0 --- /dev/null +++ b/backend/internal/service/cron.go @@ -0,0 +1,152 @@ +package service + +import ( + "fmt" + "log" + "time" + + "github.com/kickwatch/backend/internal/model" + "github.com/robfig/cron/v3" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var rootCategories = []string{ + "1", "3", "4", "5", "6", "7", "9", "10", "11", "12", "13", "14", "15", "16", "17", +} + +type CronService struct { + db *gorm.DB + restClient *KickstarterRESTClient + apnsClient *APNsClient + scheduler *cron.Cron +} + +func NewCronService(db *gorm.DB, restClient *KickstarterRESTClient, apns *APNsClient) *CronService { + return &CronService{ + db: db, + restClient: restClient, + apnsClient: apns, + scheduler: cron.New(cron.WithLocation(time.UTC)), + } +} + +func (s *CronService) Start() { + s.scheduler.AddFunc("0 2 * * *", func() { + log.Println("Cron: starting nightly crawl") + if err := s.runCrawl(); err != nil { + log.Printf("Cron: crawl error: %v", err) + } + }) + s.scheduler.Start() + log.Println("Cron scheduler started (02:00 UTC daily)") +} + +func (s *CronService) Stop() { + s.scheduler.Stop() +} + +func (s *CronService) runCrawl() error { + upserted := 0 + for _, catID := range rootCategories { + for page := 1; page <= 10; page++ { + campaigns, err := s.restClient.DiscoverCampaigns(catID, "newest", page) + if err != nil { + log.Printf("Cron: REST error cat=%s page=%d: %v", catID, page, err) + break + } + if len(campaigns) == 0 { + break + } + now := time.Now() + for i := range campaigns { + campaigns[i].LastUpdatedAt = now + } + result := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "pid"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "name", "blurb", "photo_url", "goal_amount", "goal_currency", + "pledged_amount", "deadline", "state", "category_id", "category_name", + "project_url", "creator_name", "percent_funded", "slug", "last_updated_at", + }), + }).Create(&campaigns) + if result.Error != nil { + log.Printf("Cron: upsert error: %v", result.Error) + } else { + upserted += len(campaigns) + } + time.Sleep(500 * time.Millisecond) + } + } + log.Printf("Cron: crawl done, upserted %d campaigns", upserted) + + return s.matchAlerts() +} + +func (s *CronService) matchAlerts() error { + cutoff := time.Now().Add(-25 * time.Hour) + + var alerts []model.Alert + if err := s.db.Where("is_enabled = true").Find(&alerts).Error; err != nil { + return fmt.Errorf("fetch alerts: %w", err) + } + + for _, alert := range alerts { + var campaigns []model.Campaign + query := s.db.Where( + "first_seen_at > ? AND name ILIKE ? AND percent_funded >= ?", + cutoff, "%"+alert.Keyword+"%", alert.MinPercent, + ) + if alert.CategoryID != "" { + query = query.Where("category_id = ?", alert.CategoryID) + } + if err := query.Find(&campaigns).Error; err != nil { + log.Printf("Cron: match query error for alert %s: %v", alert.ID, err) + continue + } + if len(campaigns) == 0 { + continue + } + + matches := make([]model.AlertMatch, 0, len(campaigns)) + for _, c := range campaigns { + matches = append(matches, model.AlertMatch{ + AlertID: alert.ID, + CampaignPID: c.PID, + MatchedAt: time.Now(), + }) + } + s.db.Create(&matches) + + now := time.Now() + s.db.Model(&alert).Update("last_matched_at", &now) + + s.sendAlertPush(alert, len(campaigns)) + } + return nil +} + +func (s *CronService) sendAlertPush(alert model.Alert, matchCount int) { + if s.apnsClient == nil { + return + } + var device model.Device + if err := s.db.First(&device, "id = ?", alert.DeviceID).Error; err != nil { + return + } + + payload := APNsPayload{} + payload.APS.Alert.Title = fmt.Sprintf("%d new \"%s\" campaigns", matchCount, alert.Keyword) + payload.APS.Alert.Body = "Tap to see today's matches in KickWatch" + payload.APS.Badge = 1 + payload.APS.Sound = "default" + payload.AlertID = alert.ID.String() + payload.MatchCount = matchCount + + if err := s.apnsClient.Send(device.DeviceToken, payload); err != nil { + log.Printf("Cron: APNs error for device %s: %v", device.ID, err) + if err.Error() == "apns: device token invalid (410)" { + s.db.Delete(&device) + } + } +} From 2b873dc4deb7ab70e9e345506df511576bb565c4 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:26:15 +0800 Subject: [PATCH 08/33] feat(backend): add campaigns, devices, and alerts HTTP handlers --- backend/internal/handler/alerts.go | 157 ++++++++++++++++++++++++++ backend/internal/handler/campaigns.go | 112 ++++++++++++++++++ backend/internal/handler/devices.go | 31 +++++ backend/internal/handler/health.go | 11 ++ 4 files changed, 311 insertions(+) create mode 100644 backend/internal/handler/alerts.go create mode 100644 backend/internal/handler/campaigns.go create mode 100644 backend/internal/handler/devices.go create mode 100644 backend/internal/handler/health.go diff --git a/backend/internal/handler/alerts.go b/backend/internal/handler/alerts.go new file mode 100644 index 0000000..374734c --- /dev/null +++ b/backend/internal/handler/alerts.go @@ -0,0 +1,157 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/kickwatch/backend/internal/db" + "github.com/kickwatch/backend/internal/model" +) + +type createAlertRequest struct { + DeviceID string `json:"device_id" binding:"required"` + Keyword string `json:"keyword" binding:"required"` + CategoryID string `json:"category_id"` + MinPercent float64 `json:"min_percent"` +} + +func CreateAlert(c *gin.Context) { + var req createAlertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + deviceID, err := uuid.Parse(req.DeviceID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device_id"}) + return + } + + alert := model.Alert{ + DeviceID: deviceID, + Keyword: req.Keyword, + CategoryID: req.CategoryID, + MinPercent: req.MinPercent, + IsEnabled: true, + } + if err := db.DB.Create(&alert).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, alert) +} + +func ListAlerts(c *gin.Context) { + deviceIDStr := c.Query("device_id") + if deviceIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "device_id required"}) + return + } + deviceID, err := uuid.Parse(deviceIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device_id"}) + return + } + + var alerts []model.Alert + if err := db.DB.Where("device_id = ?", deviceID).Find(&alerts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, alerts) +} + +type updateAlertRequest struct { + IsEnabled *bool `json:"is_enabled"` + Keyword *string `json:"keyword"` + CategoryID *string `json:"category_id"` + MinPercent *float64 `json:"min_percent"` +} + +func UpdateAlert(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) + return + } + + var alert model.Alert + if err := db.DB.First(&alert, "id = ?", id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"}) + return + } + + var req updateAlertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]interface{}{} + if req.IsEnabled != nil { + updates["is_enabled"] = *req.IsEnabled + } + if req.Keyword != nil { + updates["keyword"] = *req.Keyword + } + if req.CategoryID != nil { + updates["category_id"] = *req.CategoryID + } + if req.MinPercent != nil { + updates["min_percent"] = *req.MinPercent + } + + if err := db.DB.Model(&alert).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, alert) +} + +func DeleteAlert(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) + return + } + if err := db.DB.Delete(&model.Alert{}, "id = ?", id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +func GetAlertMatches(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) + return + } + + since := time.Now().Add(-24 * time.Hour) + if sinceStr := c.Query("since"); sinceStr != "" { + if t, err := time.Parse(time.RFC3339, sinceStr); err == nil { + since = t + } + } + + var matches []model.AlertMatch + if err := db.DB.Where("alert_id = ? AND matched_at > ?", id, since).Find(&matches).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + pids := make([]string, 0, len(matches)) + for _, m := range matches { + pids = append(pids, m.CampaignPID) + } + + var campaigns []model.Campaign + if len(pids) > 0 { + db.DB.Where("pid IN ?", pids).Find(&campaigns) + } + c.JSON(http.StatusOK, campaigns) +} diff --git a/backend/internal/handler/campaigns.go b/backend/internal/handler/campaigns.go new file mode 100644 index 0000000..8be2ef7 --- /dev/null +++ b/backend/internal/handler/campaigns.go @@ -0,0 +1,112 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/kickwatch/backend/internal/db" + "github.com/kickwatch/backend/internal/model" + "github.com/kickwatch/backend/internal/service" +) + +var sortMap = map[string]string{ + "trending": "MAGIC", + "newest": "NEWEST", + "ending": "END_DATE", +} + +func ListCampaigns(graphClient *service.KickstarterGraphClient) gin.HandlerFunc { + return func(c *gin.Context) { + sort := c.DefaultQuery("sort", "trending") + gqlSort, ok := sortMap[sort] + if !ok { + gqlSort = "MAGIC" + } + categoryID := c.Query("category_id") + cursor := c.Query("cursor") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + if limit > 50 { + limit = 50 + } + + result, err := graphClient.Search("", categoryID, gqlSort, cursor, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + nextCursor := "" + if result.HasNextPage { + nextCursor = result.NextCursor + } + c.JSON(http.StatusOK, gin.H{ + "campaigns": result.Campaigns, + "next_cursor": nextCursor, + "total": result.TotalCount, + }) + } +} + +func SearchCampaigns(graphClient *service.KickstarterGraphClient) gin.HandlerFunc { + return func(c *gin.Context) { + q := c.Query("q") + if q == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "q is required"}) + return + } + categoryID := c.Query("category_id") + cursor := c.Query("cursor") + + result, err := graphClient.Search(q, categoryID, "MAGIC", cursor, 20) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + nextCursor := "" + if result.HasNextPage { + nextCursor = result.NextCursor + } + c.JSON(http.StatusOK, gin.H{ + "campaigns": result.Campaigns, + "next_cursor": nextCursor, + }) + } +} + +func GetCampaign(c *gin.Context) { + pid := c.Param("pid") + if !db.IsEnabled() { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + var campaign model.Campaign + if err := db.DB.First(&campaign, "pid = ?", pid).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "campaign not found"}) + return + } + c.JSON(http.StatusOK, campaign) +} + +func ListCategories(graphClient *service.KickstarterGraphClient) gin.HandlerFunc { + return func(c *gin.Context) { + if db.IsEnabled() { + var cats []model.Category + if err := db.DB.Find(&cats).Error; err == nil && len(cats) > 0 { + c.JSON(http.StatusOK, cats) + return + } + } + + cats, err := graphClient.FetchCategories() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if db.IsEnabled() && len(cats) > 0 { + db.DB.Save(&cats) + } + c.JSON(http.StatusOK, cats) + } +} diff --git a/backend/internal/handler/devices.go b/backend/internal/handler/devices.go new file mode 100644 index 0000000..9ac53a9 --- /dev/null +++ b/backend/internal/handler/devices.go @@ -0,0 +1,31 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kickwatch/backend/internal/db" + "github.com/kickwatch/backend/internal/model" +) + +type registerDeviceRequest struct { + DeviceToken string `json:"device_token" binding:"required"` +} + +func RegisterDevice(c *gin.Context) { + var req registerDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var device model.Device + result := db.DB.Where("device_token = ?", req.DeviceToken).FirstOrCreate(&device, model.Device{ + DeviceToken: req.DeviceToken, + }) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"device_id": device.ID}) +} diff --git a/backend/internal/handler/health.go b/backend/internal/handler/health.go new file mode 100644 index 0000000..7922bb2 --- /dev/null +++ b/backend/internal/handler/health.go @@ -0,0 +1,11 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kickwatch-api"}) +} From 7d400bc48cd8680400f72f7476be1efe34479ebf Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:26:22 +0800 Subject: [PATCH 09/33] feat(backend): add Dockerfile --- backend/Dockerfile | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 backend/Dockerfile diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..72d7fce --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /api ./cmd/api + +FROM alpine:3.20 +RUN apk --no-cache add ca-certificates && \ + adduser -D -u 1001 appuser +WORKDIR /app +COPY --from=builder /api . + +USER appuser +EXPOSE 8080 +CMD ["./api"] From 83a672a03378cae99981b694ee08121b4703ac94 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:26:30 +0800 Subject: [PATCH 10/33] chore: fix gitignore to allow .env.example; add backend env example --- .gitignore | 5 +++-- backend/.env.example | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 backend/.env.example diff --git a/.gitignore b/.gitignore index 36bf299..2c125ae 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,9 @@ vendor/ # Environment .env -.env.* -*.env +.env.local +.env.production +!.env.example # Secrets Configs/Secrets.swift diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e741130 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL=postgres://user:password@localhost:5432/kickwatch?sslmode=disable +PORT=8080 + +APNS_KEY_ID=YOUR_KEY_ID +APNS_TEAM_ID=YOUR_TEAM_ID +APNS_BUNDLE_ID=com.yourname.kickwatch +APNS_KEY_PATH=/secrets/apns.p8 +APNS_ENV=sandbox From 120eba43bb0ee401852dce25f63b98e947704469 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:27:13 +0800 Subject: [PATCH 11/33] feat(ios): add project.yml, Package.swift, and asset catalog --- .../AccentColor.colorset/Contents.json | 12 +++++++ .../AppIcon.appiconset/Contents.json | 13 +++++++ ios/KickWatch/Assets.xcassets/Contents.json | 6 ++++ ios/Package.swift | 16 +++++++++ ios/project.yml | 34 +++++++++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/KickWatch/Assets.xcassets/Contents.json create mode 100644 ios/Package.swift create mode 100644 ios/project.yml diff --git a/ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..f0e4ccc --- /dev/null +++ b/ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,12 @@ +{ + "colors" : [ + { + "color" : { + "colorSpace" : "sRGB", + "components" : { "alpha" : "1.000", "blue" : "0.200", "green" : "0.478", "red" : "0.000" } + }, + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/KickWatch/Assets.xcassets/Contents.json b/ios/KickWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/KickWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 0000000..48c97e6 --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "KickWatch", + platforms: [.iOS(.v17)], + products: [ + .library(name: "KickWatch", targets: ["KickWatch"]), + ], + targets: [ + .target( + name: "KickWatch", + path: "KickWatch/Sources" + ), + ] +) diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..a2589b5 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,34 @@ +name: KickWatch +options: + bundleIdPrefix: com.kickwatch + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" +settings: + base: + SWIFT_VERSION: "5.9" +targets: + KickWatch: + type: application + platform: iOS + sources: + - path: KickWatch/Sources + - path: KickWatch/Assets.xcassets + info: + path: KickWatch/Info.plist + properties: + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" + UILaunchScreen: {} + CFBundleDisplayName: KickWatch + CFBundleIconName: AppIcon + NSUserNotificationsUsageDescription: "KickWatch sends daily digests when new campaigns match your keyword alerts." + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.kickwatch.app + INFOPLIST_FILE: KickWatch/Info.plist + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon From 519ce5c7ca24d1d6492a5c52df234ad7609f3898 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:27:44 +0800 Subject: [PATCH 12/33] feat(ios): add SwiftData models (Campaign, WatchlistAlert, RecentSearch) --- ios/KickWatch/Sources/Models/Campaign.swift | 71 +++++++++++++++++++ .../Sources/Models/RecentSearch.swift | 13 ++++ .../Sources/Models/WatchlistAlert.swift | 31 ++++++++ 3 files changed, 115 insertions(+) create mode 100644 ios/KickWatch/Sources/Models/Campaign.swift create mode 100644 ios/KickWatch/Sources/Models/RecentSearch.swift create mode 100644 ios/KickWatch/Sources/Models/WatchlistAlert.swift diff --git a/ios/KickWatch/Sources/Models/Campaign.swift b/ios/KickWatch/Sources/Models/Campaign.swift new file mode 100644 index 0000000..bb08d48 --- /dev/null +++ b/ios/KickWatch/Sources/Models/Campaign.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftData + +@Model +final class Campaign { + @Attribute(.unique) var pid: String + var name: String + var blurb: String + var photoURL: String + var goalAmount: Double + var goalCurrency: String + var pledgedAmount: Double + var deadline: Date + var state: String + var categoryName: String + var categoryID: String + var projectURL: String + var creatorName: String + var percentFunded: Double + var isWatched: Bool + var lastFetchedAt: Date + + init( + pid: String, + name: String, + blurb: String = "", + photoURL: String = "", + goalAmount: Double = 0, + goalCurrency: String = "USD", + pledgedAmount: Double = 0, + deadline: Date = .distantFuture, + state: String = "live", + categoryName: String = "", + categoryID: String = "", + projectURL: String = "", + creatorName: String = "", + percentFunded: Double = 0, + isWatched: Bool = false, + lastFetchedAt: Date = .now + ) { + self.pid = pid + self.name = name + self.blurb = blurb + self.photoURL = photoURL + self.goalAmount = goalAmount + self.goalCurrency = goalCurrency + self.pledgedAmount = pledgedAmount + self.deadline = deadline + self.state = state + self.categoryName = categoryName + self.categoryID = categoryID + self.projectURL = projectURL + self.creatorName = creatorName + self.percentFunded = percentFunded + self.isWatched = isWatched + self.lastFetchedAt = lastFetchedAt + } + + var daysLeft: Int { + max(0, Calendar.current.dateComponents([.day], from: .now, to: deadline).day ?? 0) + } + + var stateLabel: String { + switch state { + case "successful": return "Funded" + case "failed": return "Failed" + case "canceled": return "Canceled" + default: return "Live" + } + } +} diff --git a/ios/KickWatch/Sources/Models/RecentSearch.swift b/ios/KickWatch/Sources/Models/RecentSearch.swift new file mode 100644 index 0000000..7f35d05 --- /dev/null +++ b/ios/KickWatch/Sources/Models/RecentSearch.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData + +@Model +final class RecentSearch { + var query: String + var searchedAt: Date + + init(query: String, searchedAt: Date = .now) { + self.query = query + self.searchedAt = searchedAt + } +} diff --git a/ios/KickWatch/Sources/Models/WatchlistAlert.swift b/ios/KickWatch/Sources/Models/WatchlistAlert.swift new file mode 100644 index 0000000..4d3d0c0 --- /dev/null +++ b/ios/KickWatch/Sources/Models/WatchlistAlert.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftData + +@Model +final class WatchlistAlert { + @Attribute(.unique) var id: String + var keyword: String + var categoryID: String? + var minPercentFunded: Double + var isEnabled: Bool + var createdAt: Date + var lastMatchedAt: Date? + + init( + id: String = UUID().uuidString, + keyword: String, + categoryID: String? = nil, + minPercentFunded: Double = 0, + isEnabled: Bool = true, + createdAt: Date = .now, + lastMatchedAt: Date? = nil + ) { + self.id = id + self.keyword = keyword + self.categoryID = categoryID + self.minPercentFunded = minPercentFunded + self.isEnabled = isEnabled + self.createdAt = createdAt + self.lastMatchedAt = lastMatchedAt + } +} From f32b5b094c43fbc0b40c434abf4dfb22bd94445b Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:31:18 +0800 Subject: [PATCH 13/33] feat(ios): add APIClient, KeychainHelper, NotificationService, ImageCache --- .../Sources/Services/APIClient.swift | 184 ++++++++++++++++++ .../Sources/Services/ImageCache.swift | 34 ++++ .../Sources/Services/KeychainHelper.swift | 36 ++++ .../Services/NotificationService.swift | 35 ++++ 4 files changed, 289 insertions(+) create mode 100644 ios/KickWatch/Sources/Services/APIClient.swift create mode 100644 ios/KickWatch/Sources/Services/ImageCache.swift create mode 100644 ios/KickWatch/Sources/Services/KeychainHelper.swift create mode 100644 ios/KickWatch/Sources/Services/NotificationService.swift diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift new file mode 100644 index 0000000..2e6db76 --- /dev/null +++ b/ios/KickWatch/Sources/Services/APIClient.swift @@ -0,0 +1,184 @@ +import Foundation + +struct CampaignDTO: Codable { + let pid: String + let name: String + let blurb: String? + let photo_url: String? + let goal_amount: Double? + let goal_currency: String? + let pledged_amount: Double? + let deadline: String? + let state: String? + let category_name: String? + let category_id: String? + let project_url: String? + let creator_name: String? + let percent_funded: Double? + let slug: String? +} + +struct CategoryDTO: Codable { + let id: String + let name: String + let parent_id: String? +} + +struct CampaignListResponse: Codable { + let campaigns: [CampaignDTO] + let next_cursor: String? + let total: Int? +} + +struct SearchResponse: Codable { + let campaigns: [CampaignDTO] + let next_cursor: String? +} + +struct RegisterDeviceRequest: Codable { + let device_token: String +} + +struct RegisterDeviceResponse: Codable { + let device_id: String +} + +struct CreateAlertRequest: Codable { + let device_id: String + let keyword: String + let category_id: String? + let min_percent: Double? +} + +struct AlertDTO: Codable { + let id: String + let device_id: String + let keyword: String + let category_id: String? + let min_percent: Double + let is_enabled: Bool + let created_at: String + let last_matched_at: String? +} + +struct UpdateAlertRequest: Codable { + let is_enabled: Bool? + let keyword: String? + let category_id: String? + let min_percent: Double? +} + +enum APIError: LocalizedError { + case invalidURL + case invalidResponse + case serverError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid URL" + case .invalidResponse: return "Invalid server response" + case .serverError(let code): return "Server error: \(code)" + } + } +} + +actor APIClient { + static let shared = APIClient() + + private let baseURL: String + private let session: URLSession + + init(baseURL: String? = nil) { + #if DEBUG + self.baseURL = baseURL ?? "http://localhost:8080" + #else + self.baseURL = baseURL ?? "https://api.kickwatch.app" + #endif + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + self.session = URLSession(configuration: config) + } + + func fetchCampaigns(sort: String = "trending", categoryID: String? = nil, cursor: String? = nil) async throws -> CampaignListResponse { + var components = URLComponents(string: baseURL + "/api/campaigns")! + var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort)] + if let cat = categoryID { items.append(URLQueryItem(name: "category_id", value: cat)) } + if let cur = cursor { items.append(URLQueryItem(name: "cursor", value: cur)) } + components.queryItems = items + return try await get(url: components.url!) + } + + func searchCampaigns(query: String, categoryID: String? = nil, cursor: String? = nil) async throws -> SearchResponse { + var components = URLComponents(string: baseURL + "/api/campaigns/search")! + var items: [URLQueryItem] = [URLQueryItem(name: "q", value: query)] + if let cat = categoryID { items.append(URLQueryItem(name: "category_id", value: cat)) } + if let cur = cursor { items.append(URLQueryItem(name: "cursor", value: cur)) } + components.queryItems = items + return try await get(url: components.url!) + } + + func fetchCategories() async throws -> [CategoryDTO] { + return try await get(url: URL(string: baseURL + "/api/categories")!) + } + + func registerDevice(token: String) async throws -> RegisterDeviceResponse { + return try await post(url: URL(string: baseURL + "/api/devices/register")!, body: RegisterDeviceRequest(device_token: token)) + } + + func fetchAlerts(deviceID: String) async throws -> [AlertDTO] { + let url = URL(string: baseURL + "/api/alerts?device_id=\(deviceID)")! + return try await get(url: url) + } + + func createAlert(_ req: CreateAlertRequest) async throws -> AlertDTO { + return try await post(url: URL(string: baseURL + "/api/alerts")!, body: req) + } + + func updateAlert(id: String, req: UpdateAlertRequest) async throws -> AlertDTO { + return try await patch(url: URL(string: baseURL + "/api/alerts/\(id)")!, body: req) + } + + func deleteAlert(id: String) async throws { + let url = URL(string: baseURL + "/api/alerts/\(id)")! + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else { + throw APIError.invalidResponse + } + } + + func fetchAlertMatches(alertID: String) async throws -> [CampaignDTO] { + let url = URL(string: baseURL + "/api/alerts/\(alertID)/matches")! + return try await get(url: url) + } + + private func get(url: URL) async throws -> R { + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard 200..<300 ~= http.statusCode else { throw APIError.serverError(statusCode: http.statusCode) } + return try JSONDecoder().decode(R.self, from: data) + } + + private func post(url: URL, body: T) async throws -> R { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard 200..<300 ~= http.statusCode else { throw APIError.serverError(statusCode: http.statusCode) } + return try JSONDecoder().decode(R.self, from: data) + } + + private func patch(url: URL, body: T) async throws -> R { + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard 200..<300 ~= http.statusCode else { throw APIError.serverError(statusCode: http.statusCode) } + return try JSONDecoder().decode(R.self, from: data) + } +} diff --git a/ios/KickWatch/Sources/Services/ImageCache.swift b/ios/KickWatch/Sources/Services/ImageCache.swift new file mode 100644 index 0000000..7bbe6cb --- /dev/null +++ b/ios/KickWatch/Sources/Services/ImageCache.swift @@ -0,0 +1,34 @@ +import SwiftUI + +actor ImageCache { + static let shared = ImageCache() + private var cache: [URL: Image] = [:] + + func image(for url: URL) async -> Image? { + if let cached = cache[url] { return cached } + guard let (data, _) = try? await URLSession.shared.data(from: url), + let uiImage = UIImage(data: data) else { return nil } + let image = Image(uiImage: uiImage) + cache[url] = image + return image + } +} + +struct RemoteImage: View { + let urlString: String + @State private var image: Image? + + var body: some View { + Group { + if let image { + image.resizable().scaledToFill() + } else { + Rectangle().fill(Color(.systemGray5)) + .task { + guard let url = URL(string: urlString) else { return } + image = await ImageCache.shared.image(for: url) + } + } + } + } +} diff --git a/ios/KickWatch/Sources/Services/KeychainHelper.swift b/ios/KickWatch/Sources/Services/KeychainHelper.swift new file mode 100644 index 0000000..4ae57de --- /dev/null +++ b/ios/KickWatch/Sources/Services/KeychainHelper.swift @@ -0,0 +1,36 @@ +import Foundation +import Security + +enum KeychainHelper { + static func save(_ value: String, for key: String) { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + static func load(for key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(decoding: data, as: UTF8.self) + } + + static func delete(for key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/KickWatch/Sources/Services/NotificationService.swift b/ios/KickWatch/Sources/Services/NotificationService.swift new file mode 100644 index 0000000..a531b32 --- /dev/null +++ b/ios/KickWatch/Sources/Services/NotificationService.swift @@ -0,0 +1,35 @@ +import Foundation +import UserNotifications + +@MainActor +final class NotificationService: ObservableObject { + static let shared = NotificationService() + private let deviceIDKey = "kickwatch.deviceID" + + @Published var isAuthorized = false + + func requestPermission() async { + let center = UNUserNotificationCenter.current() + let granted = (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false + isAuthorized = granted + } + + func checkAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + isAuthorized = settings.authorizationStatus == .authorized + } + + func registerDeviceToken(_ tokenData: Data) async { + let token = tokenData.map { String(format: "%02x", $0) }.joined() + do { + let response = try await APIClient.shared.registerDevice(token: token) + KeychainHelper.save(response.device_id, for: deviceIDKey) + } catch { + print("NotificationService: failed to register device token: \(error)") + } + } + + var deviceID: String? { + KeychainHelper.load(for: deviceIDKey) + } +} From 10a825c36ec5a7d5f2e7bc5a26f2f39a907a8b73 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:31:39 +0800 Subject: [PATCH 14/33] feat(ios): add DiscoverViewModel and AlertsViewModel --- .../Sources/ViewModels/AlertsViewModel.swift | 55 +++++++++++++++ .../ViewModels/DiscoverViewModel.swift | 69 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift create mode 100644 ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift diff --git a/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift b/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift new file mode 100644 index 0000000..05d58ec --- /dev/null +++ b/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift @@ -0,0 +1,55 @@ +import Foundation + +@Observable +final class AlertsViewModel { + var alerts: [AlertDTO] = [] + var isLoading = false + var error: String? + + func load(deviceID: String) async { + isLoading = true + error = nil + do { + alerts = try await APIClient.shared.fetchAlerts(deviceID: deviceID) + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func createAlert(deviceID: String, keyword: String, categoryID: String?, minPercent: Double) async { + let req = CreateAlertRequest( + device_id: deviceID, + keyword: keyword, + category_id: categoryID, + min_percent: minPercent > 0 ? minPercent : nil + ) + do { + let alert = try await APIClient.shared.createAlert(req) + alerts.insert(alert, at: 0) + } catch { + self.error = error.localizedDescription + } + } + + func toggleAlert(_ alert: AlertDTO) async { + let req = UpdateAlertRequest(is_enabled: !alert.is_enabled, keyword: nil, category_id: nil, min_percent: nil) + do { + let updated = try await APIClient.shared.updateAlert(id: alert.id, req: req) + if let idx = alerts.firstIndex(where: { $0.id == alert.id }) { + alerts[idx] = updated + } + } catch { + self.error = error.localizedDescription + } + } + + func deleteAlert(_ alert: AlertDTO) async { + do { + try await APIClient.shared.deleteAlert(id: alert.id) + alerts.removeAll { $0.id == alert.id } + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift b/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift new file mode 100644 index 0000000..1f97de9 --- /dev/null +++ b/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift @@ -0,0 +1,69 @@ +import Foundation +import SwiftData + +@Observable +final class DiscoverViewModel { + var campaigns: [CampaignDTO] = [] + var categories: [CategoryDTO] = [] + var isLoading = false + var isLoadingMore = false + var error: String? + var nextCursor: String? + var hasMore = false + + var selectedSort = "trending" + var selectedCategoryID: String? + + func load() async { + isLoading = true + error = nil + do { + let resp = try await APIClient.shared.fetchCampaigns( + sort: selectedSort, categoryID: selectedCategoryID, cursor: nil + ) + campaigns = resp.campaigns + nextCursor = resp.next_cursor + hasMore = resp.next_cursor != nil + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func loadMore() async { + guard hasMore, let cursor = nextCursor, !isLoadingMore else { return } + isLoadingMore = true + do { + let resp = try await APIClient.shared.fetchCampaigns( + sort: selectedSort, categoryID: selectedCategoryID, cursor: cursor + ) + campaigns.append(contentsOf: resp.campaigns) + nextCursor = resp.next_cursor + hasMore = resp.next_cursor != nil + } catch { + self.error = error.localizedDescription + } + isLoadingMore = false + } + + func loadCategories() async { + guard categories.isEmpty else { return } + do { + categories = try await APIClient.shared.fetchCategories() + } catch { + print("DiscoverViewModel: failed to load categories: \(error)") + } + } + + func selectSort(_ sort: String) async { + selectedSort = sort + nextCursor = nil + await load() + } + + func selectCategory(_ id: String?) async { + selectedCategoryID = id + nextCursor = nil + await load() + } +} From 4530eb87ee8f918effa49e80a0acb8edf78f8a55 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:31:59 +0800 Subject: [PATCH 15/33] feat(ios): add app entry point, AppDelegate, and ContentView tab structure --- ios/KickWatch/Sources/App/AppDelegate.swift | 20 +++++++++++ ios/KickWatch/Sources/App/ContentView.swift | 19 ++++++++++ ios/KickWatch/Sources/App/KickWatchApp.swift | 38 ++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 ios/KickWatch/Sources/App/AppDelegate.swift create mode 100644 ios/KickWatch/Sources/App/ContentView.swift create mode 100644 ios/KickWatch/Sources/App/KickWatchApp.swift diff --git a/ios/KickWatch/Sources/App/AppDelegate.swift b/ios/KickWatch/Sources/App/AppDelegate.swift new file mode 100644 index 0000000..1a590dc --- /dev/null +++ b/ios/KickWatch/Sources/App/AppDelegate.swift @@ -0,0 +1,20 @@ +import UIKit + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { + await NotificationService.shared.registerDeviceToken(deviceToken) + } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("AppDelegate: failed to register for remote notifications: \(error)") + } +} diff --git a/ios/KickWatch/Sources/App/ContentView.swift b/ios/KickWatch/Sources/App/ContentView.swift new file mode 100644 index 0000000..1e2dedf --- /dev/null +++ b/ios/KickWatch/Sources/App/ContentView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + TabView { + DiscoverView() + .tabItem { Label("Discover", systemImage: "safari") } + + WatchlistView() + .tabItem { Label("Watchlist", systemImage: "heart.fill") } + + AlertsView() + .tabItem { Label("Alerts", systemImage: "bell.fill") } + + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") } + } + } +} diff --git a/ios/KickWatch/Sources/App/KickWatchApp.swift b/ios/KickWatch/Sources/App/KickWatchApp.swift new file mode 100644 index 0000000..6729b5c --- /dev/null +++ b/ios/KickWatch/Sources/App/KickWatchApp.swift @@ -0,0 +1,38 @@ +import SwiftUI +import SwiftData + +@main +struct KickWatchApp: App { + let container: ModelContainer + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + private static let schemaVersion = 1 + + init() { + let defaults = UserDefaults.standard + if defaults.integer(forKey: "schemaVersion") != Self.schemaVersion { + Self.deleteStore() + defaults.set(Self.schemaVersion, forKey: "schemaVersion") + } + do { + container = try ModelContainer(for: Campaign.self, WatchlistAlert.self, RecentSearch.self) + } catch { + Self.deleteStore() + container = try! ModelContainer(for: Campaign.self, WatchlistAlert.self, RecentSearch.self) + } + } + + private static func deleteStore() { + let url = URL.applicationSupportDirectory.appending(path: "default.store") + for ext in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(at: url.appendingPathExtension(ext)) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(container) + } +} From 14c8388c7962a5c91271b27bdfd5bfa991984018 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:33:46 +0800 Subject: [PATCH 16/33] feat(ios): add DiscoverView, CampaignRowView, CategoryChip, SearchView --- .../Sources/Views/CampaignRowView.swift | 97 +++++++++++++++++++ .../Sources/Views/CategoryChip.swift | 20 ++++ .../Sources/Views/DiscoverView.swift | 86 ++++++++++++++++ ios/KickWatch/Sources/Views/SearchView.swift | 68 +++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 ios/KickWatch/Sources/Views/CampaignRowView.swift create mode 100644 ios/KickWatch/Sources/Views/CategoryChip.swift create mode 100644 ios/KickWatch/Sources/Views/DiscoverView.swift create mode 100644 ios/KickWatch/Sources/Views/SearchView.swift diff --git a/ios/KickWatch/Sources/Views/CampaignRowView.swift b/ios/KickWatch/Sources/Views/CampaignRowView.swift new file mode 100644 index 0000000..bb05f39 --- /dev/null +++ b/ios/KickWatch/Sources/Views/CampaignRowView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct CampaignRowView: View { + let campaign: CampaignDTO + @Query private var watchlist: [Campaign] + + private var isWatched: Bool { + watchlist.contains { $0.pid == campaign.pid && $0.isWatched } + } + + var body: some View { + HStack(spacing: 12) { + thumbnail + info + Spacer() + watchButton + } + .padding(.vertical, 10) + .padding(.leading, 16) + } + + private var thumbnail: some View { + RemoteImage(urlString: campaign.photo_url ?? "") + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var info: some View { + VStack(alignment: .leading, spacing: 4) { + Text(campaign.name) + .font(.subheadline).fontWeight(.semibold) + .lineLimit(2) + if let creator = campaign.creator_name { + Text("by \(creator)") + .font(.caption).foregroundStyle(.secondary) + } + fundingBar + HStack(spacing: 8) { + Text("\(Int(campaign.percent_funded ?? 0))% funded") + .font(.caption2).foregroundStyle(.secondary) + if let deadline = campaign.deadline, let date = ISO8601DateFormatter().date(from: deadline) { + let days = max(0, Calendar.current.dateComponents([.day], from: .now, to: date).day ?? 0) + Text("\(days)d left") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + + private var fundingBar: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2).fill(Color(.systemGray5)).frame(height: 4) + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor) + .frame(width: min(geo.size.width * CGFloat((campaign.percent_funded ?? 0) / 100), geo.size.width), height: 4) + } + } + .frame(height: 4) + } + + @Environment(\.modelContext) private var modelContext + + private var watchButton: some View { + Button { + toggleWatch() + } label: { + Image(systemName: isWatched ? "heart.fill" : "heart") + .foregroundStyle(isWatched ? .red : .secondary) + } + .buttonStyle(.plain) + } + + private func toggleWatch() { + if let existing = watchlist.first(where: { $0.pid == campaign.pid }) { + existing.isWatched.toggle() + } else { + let c = Campaign( + pid: campaign.pid, + name: campaign.name, + blurb: campaign.blurb ?? "", + photoURL: campaign.photo_url ?? "", + goalAmount: campaign.goal_amount ?? 0, + goalCurrency: campaign.goal_currency ?? "USD", + pledgedAmount: campaign.pledged_amount ?? 0, + state: campaign.state ?? "live", + categoryName: campaign.category_name ?? "", + categoryID: campaign.category_id ?? "", + projectURL: campaign.project_url ?? "", + creatorName: campaign.creator_name ?? "", + percentFunded: campaign.percent_funded ?? 0, + isWatched: true + ) + modelContext.insert(c) + } + try? modelContext.save() + } +} diff --git a/ios/KickWatch/Sources/Views/CategoryChip.swift b/ios/KickWatch/Sources/Views/CategoryChip.swift new file mode 100644 index 0000000..2a84041 --- /dev/null +++ b/ios/KickWatch/Sources/Views/CategoryChip.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct CategoryChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor : Color(.systemGray5)) + .foregroundStyle(isSelected ? .white : .primary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} diff --git a/ios/KickWatch/Sources/Views/DiscoverView.swift b/ios/KickWatch/Sources/Views/DiscoverView.swift new file mode 100644 index 0000000..05681e9 --- /dev/null +++ b/ios/KickWatch/Sources/Views/DiscoverView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import SwiftData + +struct DiscoverView: View { + @State private var vm = DiscoverViewModel() + @State private var searchText = "" + @State private var showSearch = false + + private let sortOptions = [("trending", "Trending"), ("newest", "New"), ("ending", "Ending Soon")] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + sortPicker + categoryScroll + campaignList + } + .navigationTitle("Discover") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showSearch = true } label: { Image(systemName: "magnifyingglass") } + } + } + .sheet(isPresented: $showSearch) { SearchView() } + .task { await vm.loadCategories(); await vm.load() } + .refreshable { await vm.load() } + } + } + + private var sortPicker: some View { + Picker("Sort", selection: Binding( + get: { vm.selectedSort }, + set: { Task { await vm.selectSort($0) } } + )) { + ForEach(sortOptions, id: \.0) { Text($1).tag($0) } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + } + + private var categoryScroll: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + CategoryChip(title: "All", isSelected: vm.selectedCategoryID == nil) { + Task { await vm.selectCategory(nil) } + } + ForEach(vm.categories.filter { $0.parent_id == nil }, id: \.id) { cat in + CategoryChip(title: cat.name, isSelected: vm.selectedCategoryID == cat.id) { + Task { await vm.selectCategory(cat.id) } + } + } + } + .padding(.horizontal) + } + .padding(.bottom, 4) + } + + private var campaignList: some View { + Group { + if vm.isLoading && vm.campaigns.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let err = vm.error { + Text(err).foregroundStyle(.secondary).padding() + } else { + List { + ForEach(vm.campaigns, id: \.pid) { campaign in + NavigationLink(destination: CampaignDetailView(campaign: campaign)) { + CampaignRowView(campaign: campaign) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) + .onAppear { + if campaign.pid == vm.campaigns.last?.pid { + Task { await vm.loadMore() } + } + } + } + if vm.isLoadingMore { + ProgressView().frame(maxWidth: .infinity) + } + } + .listStyle(.plain) + } + } + } +} diff --git a/ios/KickWatch/Sources/Views/SearchView.swift b/ios/KickWatch/Sources/Views/SearchView.swift new file mode 100644 index 0000000..01a329f --- /dev/null +++ b/ios/KickWatch/Sources/Views/SearchView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import SwiftData + +struct SearchView: View { + @State private var query = "" + @State private var results: [CampaignDTO] = [] + @State private var isLoading = false + @State private var nextCursor: String? + @State private var hasMore = false + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if isLoading && results.isEmpty { + ProgressView().frame(maxWidth: .infinity) + } else { + ForEach(results, id: \.pid) { campaign in + NavigationLink(destination: CampaignDetailView(campaign: campaign)) { + CampaignRowView(campaign: campaign) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) + } + if hasMore { + ProgressView().frame(maxWidth: .infinity) + .task { await loadMore() } + } + } + } + .listStyle(.plain) + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always)) + .onSubmit(of: .search) { Task { await search() } } + .onChange(of: query) { _, new in if new.isEmpty { results = [] } } + } + } + + private func search() async { + guard !query.isEmpty else { return } + isLoading = true + do { + let resp = try await APIClient.shared.searchCampaigns(query: query) + results = resp.campaigns + nextCursor = resp.next_cursor + hasMore = resp.next_cursor != nil + } catch { + print("SearchView: \(error)") + } + isLoading = false + } + + private func loadMore() async { + guard let cursor = nextCursor, !isLoading else { return } + isLoading = true + do { + let resp = try await APIClient.shared.searchCampaigns(query: query, cursor: cursor) + results.append(contentsOf: resp.campaigns) + nextCursor = resp.next_cursor + hasMore = resp.next_cursor != nil + } catch { + print("SearchView loadMore: \(error)") + } + isLoading = false + } +} From 702f3f256ef306438e128d1b3024b1d5798d5b37 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:34:16 +0800 Subject: [PATCH 17/33] feat(ios): add CampaignDetailView with funding ring and back link --- .../Sources/Views/CampaignDetailView.swift | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 ios/KickWatch/Sources/Views/CampaignDetailView.swift diff --git a/ios/KickWatch/Sources/Views/CampaignDetailView.swift b/ios/KickWatch/Sources/Views/CampaignDetailView.swift new file mode 100644 index 0000000..b244edc --- /dev/null +++ b/ios/KickWatch/Sources/Views/CampaignDetailView.swift @@ -0,0 +1,159 @@ +import SwiftUI +import SwiftData + +struct CampaignDetailView: View { + let campaign: CampaignDTO + @Query private var watchlist: [Campaign] + @Environment(\.modelContext) private var modelContext + + private var isWatched: Bool { + watchlist.contains { $0.pid == campaign.pid && $0.isWatched } + } + + private var deadline: Date? { + campaign.deadline.flatMap { ISO8601DateFormatter().date(from: $0) } + } + + private var daysLeft: Int { + guard let d = deadline else { return 0 } + return max(0, Calendar.current.dateComponents([.day], from: .now, to: d).day ?? 0) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + heroImage + content + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarItems } + } + + private var heroImage: some View { + RemoteImage(urlString: campaign.photo_url ?? "") + .frame(maxWidth: .infinity) + .frame(height: 240) + .clipped() + } + + private var content: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(campaign.name) + .font(.title2).fontWeight(.bold) + if let creator = campaign.creator_name { + Text("by \(creator)").font(.subheadline).foregroundStyle(.secondary) + } + if let cat = campaign.category_name { + Text(cat).font(.caption).padding(.horizontal, 8).padding(.vertical, 3) + .background(Color(.systemGray5)).clipShape(Capsule()) + } + } + + fundingStats + + if let url = campaign.project_url, let link = URL(string: url) { + Link(destination: link) { + Label("Back this project", systemImage: "arrow.up.right.square.fill") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } + .padding() + } + + private var fundingStats: some View { + VStack(spacing: 12) { + fundingRing + HStack { + statBox(label: "Goal", value: formattedAmount(campaign.goal_amount, currency: campaign.goal_currency)) + Divider() + statBox(label: "Pledged", value: formattedAmount(campaign.pledged_amount, currency: campaign.goal_currency)) + Divider() + statBox(label: "Days Left", value: "\(daysLeft)") + } + .frame(height: 60) + .padding() + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + + private var fundingRing: some View { + let pct = min((campaign.percent_funded ?? 0) / 100, 1.0) + return ZStack { + Circle().stroke(Color(.systemGray5), lineWidth: 12) + Circle().trim(from: 0, to: pct).stroke(Color.accentColor, style: StrokeStyle(lineWidth: 12, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text("\(Int((campaign.percent_funded ?? 0)))%").font(.title2).fontWeight(.bold) + Text("funded").font(.caption).foregroundStyle(.secondary) + } + } + .frame(width: 120, height: 120) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private func statBox(label: String, value: String) -> some View { + VStack(spacing: 2) { + Text(value).font(.subheadline).fontWeight(.semibold) + Text(label).font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + private func formattedAmount(_ amount: Double?, currency: String?) -> String { + guard let amount else { return "—" } + let sym = currency == "USD" ? "$" : (currency ?? "") + if amount >= 1_000_000 { return "\(sym)\(String(format: "%.1fM", amount / 1_000_000))" } + if amount >= 1_000 { return "\(sym)\(String(format: "%.0fK", amount / 1_000))" } + return "\(sym)\(Int(amount))" + } + + @ToolbarContentBuilder + private var toolbarItems: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + HStack { + if let url = campaign.project_url, let link = URL(string: url) { + ShareLink(item: link) + } + Button { toggleWatch() } label: { + Image(systemName: isWatched ? "heart.fill" : "heart") + .foregroundStyle(isWatched ? .red : .primary) + } + } + } + } + + private func toggleWatch() { + if let existing = watchlist.first(where: { $0.pid == campaign.pid }) { + existing.isWatched.toggle() + } else { + let c = Campaign( + pid: campaign.pid, + name: campaign.name, + blurb: campaign.blurb ?? "", + photoURL: campaign.photo_url ?? "", + goalAmount: campaign.goal_amount ?? 0, + goalCurrency: campaign.goal_currency ?? "USD", + pledgedAmount: campaign.pledged_amount ?? 0, + deadline: deadline ?? .distantFuture, + state: campaign.state ?? "live", + categoryName: campaign.category_name ?? "", + categoryID: campaign.category_id ?? "", + projectURL: campaign.project_url ?? "", + creatorName: campaign.creator_name ?? "", + percentFunded: campaign.percent_funded ?? 0, + isWatched: true + ) + modelContext.insert(c) + } + try? modelContext.save() + } +} From bcf78a1cd58cbd63f8bc557abf092d4aea838027 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:35:04 +0800 Subject: [PATCH 18/33] feat(ios): add WatchlistView with swipe-to-remove and status badges --- .../Sources/Views/WatchlistView.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 ios/KickWatch/Sources/Views/WatchlistView.swift diff --git a/ios/KickWatch/Sources/Views/WatchlistView.swift b/ios/KickWatch/Sources/Views/WatchlistView.swift new file mode 100644 index 0000000..5abd002 --- /dev/null +++ b/ios/KickWatch/Sources/Views/WatchlistView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import SwiftData + +struct WatchlistView: View { + @Query(filter: #Predicate { $0.isWatched }, sort: \Campaign.deadline) + private var campaigns: [Campaign] + + @Environment(\.modelContext) private var modelContext + + var body: some View { + NavigationStack { + Group { + if campaigns.isEmpty { + emptyState + } else { + List { + ForEach(campaigns) { campaign in + NavigationLink(destination: CampaignDetailView(campaign: toCampaignDTO(campaign))) { + WatchlistRowView(campaign: campaign) + } + } + .onDelete(perform: remove) + } + .listStyle(.plain) + } + } + .navigationTitle("Watchlist") + } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "heart.slash").font(.system(size: 48)).foregroundStyle(.secondary) + Text("No saved campaigns").font(.headline) + Text("Tap the heart icon on any campaign to add it here.") + .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center) + } + .padding() + } + + private func remove(at offsets: IndexSet) { + for idx in offsets { + campaigns[idx].isWatched = false + } + try? modelContext.save() + } + + private func toCampaignDTO(_ c: Campaign) -> CampaignDTO { + CampaignDTO( + pid: c.pid, name: c.name, blurb: c.blurb, photo_url: c.photoURL, + goal_amount: c.goalAmount, goal_currency: c.goalCurrency, + pledged_amount: c.pledgedAmount, + deadline: ISO8601DateFormatter().string(from: c.deadline), + state: c.state, category_name: c.categoryName, category_id: c.categoryID, + project_url: c.projectURL, creator_name: c.creatorName, + percent_funded: c.percentFunded, slug: nil + ) + } +} + +struct WatchlistRowView: View { + let campaign: Campaign + + var body: some View { + HStack(spacing: 12) { + RemoteImage(urlString: campaign.photoURL) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + Text(campaign.name).font(.subheadline).fontWeight(.semibold).lineLimit(2) + Text(campaign.creatorName).font(.caption).foregroundStyle(.secondary) + HStack { + stateBadge + Text("\(Int(campaign.percentFunded))% • \(campaign.daysLeft)d left") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private var stateBadge: some View { + Text(campaign.stateLabel) + .font(.caption2).fontWeight(.medium) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(badgeColor.opacity(0.15)) + .foregroundStyle(badgeColor) + .clipShape(Capsule()) + } + + private var badgeColor: Color { + switch campaign.state { + case "successful": return .green + case "failed", "canceled": return .red + default: return .accentColor + } + } +} From 972d9f928c2ea51020864874ff02374e5d4045a9 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:35:07 +0800 Subject: [PATCH 19/33] feat(ios): add AlertsView, NewAlertSheet, AlertMatchesView --- ios/KickWatch/Sources/Views/AlertsView.swift | 151 +++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 ios/KickWatch/Sources/Views/AlertsView.swift diff --git a/ios/KickWatch/Sources/Views/AlertsView.swift b/ios/KickWatch/Sources/Views/AlertsView.swift new file mode 100644 index 0000000..4c95e6e --- /dev/null +++ b/ios/KickWatch/Sources/Views/AlertsView.swift @@ -0,0 +1,151 @@ +import SwiftUI + +struct AlertsView: View { + @State private var vm = AlertsViewModel() + @State private var showNewAlert = false + + var body: some View { + NavigationStack { + Group { + if vm.isLoading && vm.alerts.isEmpty { + ProgressView() + } else if vm.alerts.isEmpty { + emptyState + } else { + alertList + } + } + .navigationTitle("Alerts") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showNewAlert = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showNewAlert) { NewAlertSheet(vm: vm) } + .task { + if let deviceID = NotificationService.shared.deviceID { + await vm.load(deviceID: deviceID) + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "bell.slash").font(.system(size: 48)).foregroundStyle(.secondary) + Text("No alerts yet").font(.headline) + Text("Create a keyword alert to get notified when matching campaigns launch.") + .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center) + Button("Create Alert") { showNewAlert = true } + .buttonStyle(.borderedProminent) + } + .padding() + } + + private var alertList: some View { + List { + ForEach(vm.alerts, id: \.id) { alert in + NavigationLink(destination: AlertMatchesView(alert: alert)) { + AlertRowView(alert: alert, vm: vm) + } + } + .onDelete { offsets in + let toDelete = offsets.map { vm.alerts[$0] } + for alert in toDelete { Task { await vm.deleteAlert(alert) } } + } + } + } +} + +struct AlertRowView: View { + let alert: AlertDTO + let vm: AlertsViewModel + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\"\(alert.keyword)\"").font(.subheadline).fontWeight(.semibold) + Group { + if let cat = alert.category_id { Text("Category: \(cat)") } + if alert.min_percent > 0 { Text("Min \(Int(alert.min_percent))% funded") } + } + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { alert.is_enabled }, + set: { _ in Task { await vm.toggleAlert(alert) } } + )) + .labelsHidden() + } + .padding(.vertical, 4) + } +} + +struct NewAlertSheet: View { + let vm: AlertsViewModel + @State private var keyword = "" + @State private var minPercent = 0.0 + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Keyword") { + TextField("e.g. mechanical keyboard", text: $keyword) + } + Section("Min % Funded") { + Slider(value: $minPercent, in: 0...100, step: 10) { + Text("\(Int(minPercent))%") + } + Text("\(Int(minPercent))% minimum").foregroundStyle(.secondary) + } + } + .navigationTitle("New Alert") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + guard !keyword.isEmpty, let deviceID = NotificationService.shared.deviceID else { return } + Task { + await vm.createAlert(deviceID: deviceID, keyword: keyword, categoryID: nil, minPercent: minPercent) + dismiss() + } + } + .disabled(keyword.isEmpty) + } + } + } + } +} + +struct AlertMatchesView: View { + let alert: AlertDTO + @State private var campaigns: [CampaignDTO] = [] + @State private var isLoading = false + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if campaigns.isEmpty { + Text("No matches yet").foregroundStyle(.secondary) + } else { + List(campaigns, id: \.pid) { campaign in + NavigationLink(destination: CampaignDetailView(campaign: campaign)) { + CampaignRowView(campaign: campaign) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) + } + .listStyle(.plain) + } + } + .navigationTitle("\"\(alert.keyword)\" matches") + .task { + isLoading = true + campaigns = (try? await APIClient.shared.fetchAlertMatches(alertID: alert.id)) ?? [] + isLoading = false + } + } +} From 9097d995309ec79e9405b2540b40bc56c5d955f4 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:35:12 +0800 Subject: [PATCH 20/33] feat(ios): add SettingsView with notification opt-in and app version --- .../Sources/Views/SettingsView.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 ios/KickWatch/Sources/Views/SettingsView.swift diff --git a/ios/KickWatch/Sources/Views/SettingsView.swift b/ios/KickWatch/Sources/Views/SettingsView.swift new file mode 100644 index 0000000..eca2986 --- /dev/null +++ b/ios/KickWatch/Sources/Views/SettingsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SettingsView: View { + @StateObject private var notificationService = NotificationService.shared + + var body: some View { + NavigationStack { + Form { + Section("Notifications") { + HStack { + Label("Push Notifications", systemImage: "bell") + Spacer() + if notificationService.isAuthorized { + Text("Enabled").foregroundStyle(.secondary) + } else { + Button("Enable") { + Task { await notificationService.requestPermission() } + } + } + } + } + Section("About") { + LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—") + LabeledContent("Build", value: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—") + } + } + .navigationTitle("Settings") + .task { await notificationService.checkAuthorizationStatus() } + } + } +} From c5bb875389306e2bb5f2d3ecb85ba71b201fcb32 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:35:45 +0800 Subject: [PATCH 21/33] feat(ci): add backend test and ECS deploy workflows --- .github/workflows/deploy-backend.yml | 75 ++++++++++++++++++++++++++++ .github/workflows/test-backend.yml | 25 ++++++++++ 2 files changed, 100 insertions(+) create mode 100644 .github/workflows/deploy-backend.yml create mode 100644 .github/workflows/test-backend.yml diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..0379562 --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,75 @@ +name: Deploy Backend + +on: + push: + branches: [main] + paths: + - "backend/**" + - ".github/workflows/deploy-backend.yml" + +env: + AWS_REGION: us-east-1 + ECR_REPOSITORY: kickwatch-backend + ECS_CLUSTER: kickwatch-cluster + ECS_SERVICE: kickwatch-backend-service + CONTAINER_NAME: kickwatch-backend + +jobs: + deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: backend/go.sum + + - run: go test ./... + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, push image + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Download ECS task definition + run: | + aws ecs describe-task-definition \ + --task-definition kickwatch-backend \ + --query taskDefinition \ + > task-definition.json + + - name: Update ECS task definition with new image + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: backend/task-definition.json + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.build-image.outputs.image }} + + - name: Deploy to ECS + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 0000000..088abf5 --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,25 @@ +name: Test Backend + +on: + push: + paths: + - "backend/**" + pull_request: + paths: + - "backend/**" + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: backend/go.sum + - run: go build ./... + - run: go test ./... + - run: go vet ./... From ddb1202fd7a11c69ecee4bf19b140d5639cc5e4a Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 12:36:34 +0800 Subject: [PATCH 22/33] fix(backend): remove duplicate json tag in restProject struct --- backend/internal/service/kickstarter_rest.go | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/internal/service/kickstarter_rest.go b/backend/internal/service/kickstarter_rest.go index 50ab211..d4795bc 100644 --- a/backend/internal/service/kickstarter_rest.go +++ b/backend/internal/service/kickstarter_rest.go @@ -23,7 +23,6 @@ type restProject struct { Pledged string `json:"pledged"` Currency string `json:"currency"` Deadline int64 `json:"deadline"` - URL string `json:"urls"` Slug string `json:"slug"` Photo struct { Full string `json:"full"` From 21f26614914f69270a4145e7eac3977cdf9536a8 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:00:15 +0800 Subject: [PATCH 23/33] feat(ios): add app icon in all required sizes from final logo --- .../AppIcon.appiconset/AppIcon-1024x1024.png | Bin 0 -> 33439 bytes .../AppIcon.appiconset/AppIcon-120x120.png | Bin 0 -> 3404 bytes .../AppIcon.appiconset/AppIcon-152x152.png | Bin 0 -> 4286 bytes .../AppIcon.appiconset/AppIcon-167x167.png | Bin 0 -> 4760 bytes .../AppIcon.appiconset/AppIcon-180x180.png | Bin 0 -> 5133 bytes .../AppIcon.appiconset/AppIcon-20x20.png | Bin 0 -> 542 bytes .../AppIcon.appiconset/AppIcon-29x29.png | Bin 0 -> 786 bytes .../AppIcon.appiconset/AppIcon-40x40.png | Bin 0 -> 1082 bytes .../AppIcon.appiconset/AppIcon-58x58.png | Bin 0 -> 1613 bytes .../AppIcon.appiconset/AppIcon-60x60.png | Bin 0 -> 1670 bytes .../AppIcon.appiconset/AppIcon-76x76.png | Bin 0 -> 2152 bytes .../AppIcon.appiconset/AppIcon-80x80.png | Bin 0 -> 2281 bytes .../AppIcon.appiconset/AppIcon-87x87.png | Bin 0 -> 2502 bytes .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 33439 bytes .../AppIcon.appiconset/AppIcon.svg | 17 +++ .../AppIcon.appiconset/Contents.json | 107 +++++++++++++++++- 16 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png new file mode 100644 index 0000000000000000000000000000000000000000..62510574ad804eceb864345faa32430d0999cc3b GIT binary patch literal 33439 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 U7|I1uSML!3gVI&6SF?-xA8fv7?*IS* literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..d714beefe162c465f4e06a5e38d831d0f0d89e10 GIT binary patch literal 4286 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 literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png new file mode 100644 index 0000000000000000000000000000000000000000..225fd429aa37fe70802e7bd13e135906de3da5fb GIT binary patch literal 4760 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 z zQOrY<{R#FP3~M%WxIG?yoa%k$AOM9tsA!I_oaE&NWajaFT|^lgFmGJsdI#xRk5`im z$D>JY9(1*8hI62;(dW^7ZXHGxFH3C{8Zd2SrLhiq>Gtan9g3%(@!(so#O=>Imf9Gs zcUhi0&ln$C5(l1QY`KA@uZ3TkCp?vPIjOGNB{OfnC479zjL2zt%`%s0r5`gXDXcnT zqCY1CmeoEdTzrsT%Sz^4UD!V1?^mCi8jVLi*@Viy%cP4YDSz57DCtXl-|!k8`^m#( z#BJK*<$UyoJArFX6V^o({L>}lgEm~%=HWNPLu<}*8(jUaAmdZmFg}o-!SdS#PX&4E zJznJL4W)WQk}nEWSE;vXb5+i(H>5Et_jQxVKm-biM@JhVNRS0mcIBE*1Ged76%V)u z4O#s8dk^9LopIlDfIgk6)R{dDTdRDC5-JuAY5vO^n%`!rJt4#6B@PYDeSOf>!(Ztx z)C{#EDI%&(4;*MqeE`{vGY!o>V7YtiJJl$X{xt?y=Ixyg@=o1l&3q``TTyuQLr-MY zqU`B3Dd4Fik0vX@=m1QL^_Rp^I=&x*VlAs~5i2#~T7^qD&hoMM^%(KdJ>mV7eYe!j z`J1z+q^#DzeDSN5aDiV4yIX)vUT&QGK>(QL!l^Mp*k9MFvGYeQkvXH<699~IJ5xkJ z9XB#A{r|eFSd|Jbdp9k7zkJpmzWbpxGsk*h`GnS=3fm8LjQstWJ%1BT8H)VcEX`9; z@L=G2xJ8r}KIe#T1ik@hmq;(F2i?1@xi#qD9Cy;vbg)cbCa|T|aS{x!s=UQ50y&5s zrfMcK_~MiVq}3c}Bg|`b1g@eb)Gaq&1PJfGTgP?~Bc%z!rtm`Ei`jvuOxE}LwD99% zH3Bd0+#mL$R0^4pBkQ~ueDFq!&wkWULH1^>Wq=f_>Knr57!SKT)vr=3+?omQ2o;Dt z%EJHAxsttF?ieiGV@(H3fnZlvDA??rQA*guv`*4~BD9BFl z-T~j?cp&Z7tU6qdX~(D5t4cz&X4FFbR?zP6XP^eJd8*{zzCzEjb1W&#&ZJIRnbUg2 zA5K~FHL%WqY#=#A(krO7j!uYOf$p})SFn{i(4|8b8=&tMS*l2EvV1!{MjsqT%|+e! z7T>vjTRDTF<+Xk~lt7*fJr(uwkW@AEK`kN4t&o#N7gV%e9y|Bbh8=q&DMs|sNhWL^Fu04 zA`3MjGdrkI1lIjjis2)2*w9B;Vg(^K@pBFA6Y;$m?7J`ex*_}vvBRj)l!lXh(L+~f5Sgw#En zEw^|b-qJ)0Nga_2#EbaA@?bbvW3LNTJ??Jz=y29T8H9<(6rXM%Zr=;5oy{XOd`Yy5 z_NeusTr`kAPKB@)uh~)k;S(&mNg&&D6Q+@s@b>>seg7^(ZNpkLY*mSg44BY`50eJG zbF=wIoB9GYNDTng{CX=Z__!%@WFm4iI}-NM{;WjF!W@rGgOnvJp|6#V|9{$wedf8= YguaSuWvh0;wbTR{fQ@yl?>oKtKOl)<2><{9 literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..d0a98f4bb703da2572067e2f3e02c70dd747f289 GIT binary patch literal 5133 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 literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png new file mode 100644 index 0000000000000000000000000000000000000000..a1aac0ec872e1a8cd648c44d89e2b4cd3405cd47 GIT binary patch literal 786 zcmV+t1MU2YP)?>2K~zYI?UhSU6Hye$&pbNQP9H;C+9^eA0#pPfip0l)C}@n21q&A@ z#)S+01TJ*x#t-1axG~Yh7?&uC35hW-(BdmGu@Dl0K;_6nhvKyc0!b&vBIBmnnhPKPi6!Q}^;aoIc zOk~QZh{p}j($?IPir>wKQ$M08E@O=9UByf;l3r`JI&7?2mqj%?zXZtp$n?*}WgN?( zQiQ7CGo;>Qjsood>rszg!p zxg0W_y#Y6`mTs+#RW@0J98)}d&35Rz_c+g+MbSYSy|9M+ZGCBcb}|`@fz=?iDpe&< zA|aKdZ;1UY%eK($$mj?q3Z6jgKwGb#`ylkfwUoC5uoSa77bpS%PiNku9{P@S0b>l9 zDA8m>;GwM&QtOT1M4S}`o-rA40l@3`{mG?)s^8w88(l0U zYg|Gw#uP;{O@cP7PNzHiG3ZOCwA^#ArkbOuAI`6SA{S)WOw+;ldouh(hQKS{UmZVrs`npVhIc^RK7uS4m1Fp}S= zAGpDtVe*QG$&FN`;c#nEXEflRXz%@dXcWn-7KPO|7dE+nx2fIsKi+NJUuez^c$Js) QM*si-07*qoM6N<$f;%W&CQVY6R=7&})kviTBrZsB zLE?hcOC^MaT7l?a00JR#Daw^1#0?1nLP6p{5lW>t^p}LvM6|Y>Uv6SMj_btsj=f&b zaM%(#?yT)bt%^{-+QaOdH}jddGjC>AV2r_&koZKXd*D6ru2gH(8FbDR`BCv2lwMl6 znNP1a@}=mrhmH*%e(c~YE4S`0F426cUNbDq+TB%neBimDc_C z!Ar&5U&;AKcC%K}?R;t#a%+7h>m6ltgKs7LNChay#r0@m5X0oSY?V8zM20;#6@D@`vveFhU5)@-8QQWO{o048W4p z4(wEM$j#aR_~7y3IM@Eg@`V)1HyAy?dikTok7pA<5n9h0r8Bp`w*!OM#WnK}iFkPM zqGS3&dqQ7-dgfC_lxx%kfXVdqWSZmFEKjI4gDu%!Z)vY;S7lMV5d#?GO>jGKeBUU4 z5qBpm;|-S_aPbc~Tkvu1Fm>$yx^Ioy^ec7_wp~l27>Yi%Yw#VTU@{sDM{Gyd=9alt zCk3PP4cShr1NAy>7fQF++efbd(cj`8_`e4J2m4%W4^p``g8%>k07*qoM6N<$f~sQ> A6aWAK literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png new file mode 100644 index 0000000000000000000000000000000000000000..6a14a5ed5a710598ca9aa9afb1ac8009c7f08ab5 GIT binary patch literal 1613 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 QdH?_b07*qoM6N<$f`fV|^8f$< literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..a8917bb9e6352cca295301d5b3ac50ef321f0258 GIT binary patch literal 2152 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 D^Mz&r literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png new file mode 100644 index 0000000000000000000000000000000000000000..38ca7d11b68f84df8b0eb767b90b9a7a4b0b267a GIT binary patch literal 2502 zcmV;%2|4zOP)ZojS&sUXn~_ zQakB1Z8K?_aVOKuOxx)s4}IyR89yYMPTQGIT+cYI)8xUOw4L_BNo!|XkDCy?c3fi& zcnk&%27%CBNV}5u(udUuf+g+lkpO$Lzb81C@0|VbIY;039f^=-8GPm;H+lm11cnL# z7%C_jDga=ppkSx~fT4ndp#lJg3JQh_02nGL7%Bi@sGx9Psvv_h$HC_Enp;z3AYYss z@lDT4!FbQ!U0z2+xh&rwDhw@5-I^Ge{IN8bQc6Z9$3H77+zR=wkKd5|vD8v>Rh6(9 zE8{@-q6+>*^pjh|+*~Lfi5F^kP&b!L=qWE zq)1cglOfhW2R-PUn7Tb7%*Qi4U!tk{?OXc2J*7`+{no;;e{y`{mgtYClchd=&5}cmr#}v=BFivzY~wGXJsv z`+FU2x!QC(&GRWy6zulwrWhC)NJ}D7#W2;@EzMq!)v0kHeJVs!OCO9~nZ9$!7L!=X zbjO_hnd7?l?s{3N0sw$Cn}6N;l!s?Hj^}w!6h#0)Wu;~J?reNLFfg#RwCthMEJN5B zOO3s+Wpg!aEi#a%b73YP_}9(~)=p7GW46a=9DuEy9Nt48j&E{fud zbML*m_k}Mwy?NRfay=_x@qtuM$LO@Lj;9JeYuPj6T95_t$ob=E7f16{71-UB-BR49 z%36?ZELg}Cs$J`~PzXs7kDNdL`sPF4R3%B0y}i9kc~@6gC=?8Z!jVWsIW-ho8a>4g zs;q^rEqNB+y?`u;FOL3oH0@{E<#WMgGPSg9>g(&zo(cHg-gM&QJ+wFF{-5!r;eFw6NRb8_xk7p)LF`L;E z()6f>AOLZ^vtV?_WZDMZ{FH()^683P)A2ce}Dg;J&)uWcc80R2O*@ZtE*F~?1@8G74)fAqUqSHb-O`qLGPS$SWE@?HY5v9 zV44r>9Vn4l^7|7rGc&moTFp~X&=gZ1Y}QXF@mL^nI+Q7Desbu|KqjHp_=>;B#>Ntf<&LmM z-BVD|x)wG&8Uu8}fTE0?4h|14&1!F7#4Gf=wR$=_GH!%Z(|Mbxqn^8#r(C^Xb3KSR z&`KyscQ~6gHrAm+@1{~JZrmI-t;Z(?LsIW< z{Z1{dbyw#FI*O&(R-tD{0D`O5bZc5V4bb+w_p7B?Xlv9F{^-lZw|iRE8nBb8QAJ@>*}Ig1qV~P$cpOiRv-iU>%)I|dGh_aR1^RZ6Zn(9i+iv9MsbrRS^n;g zQ$M=-mnl970Ei0w+w+%pUH;|Gq+jpc6bL<@f&xxjwW`huZs`mD(~@{}>deupGgdQ- z3#nBX8KNv5oBikEfu3b7y^^eH-?DOibX5^etTi06=Gx?%M+# z6mb2JO%+YPPxb_QL%B0_D=36kH!L4h4X%!p_z%utE2f#bK&-9|{0lg}yp( zh+2Fu%XW#G~l4W^ZiX5bB@$8iI_YoOMOk~RT3lQxP1X)FwbX0G^FNW?x z?#tg5${R8TNf0KiZ|!B7DJLj?sx1po{c6buyrFjP?ZKY-ax)c2yC QtN;K207*qoM6N<$f+!HgM*si- literal 0 HcmV?d00001 diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..62510574ad804eceb864345faa32430d0999cc3b GIT binary patch literal 33439 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 + + + + + + + + + + + + + + + + diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..10c27ca 100644 --- a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,8 +1,111 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", + "filename" : "AppIcon-40x40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-60x60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-58x58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-87x87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-80x80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-120x120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-120x120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AppIcon-180x180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "AppIcon-20x20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-40x40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-29x29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-58x58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-40x40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-80x80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-76x76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-152x152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-167x167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "AppIcon-1024x1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", "size" : "1024x1024" } ], From a956228fd36e48f18bd9fb7009ce002856e4d2e1 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:06:55 +0800 Subject: [PATCH 24/33] feat(ci): add Dockerfile.ci for CI pre-built binary deploy --- backend/Dockerfile.ci | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 backend/Dockerfile.ci diff --git a/backend/Dockerfile.ci b/backend/Dockerfile.ci new file mode 100644 index 0000000..1add1a5 --- /dev/null +++ b/backend/Dockerfile.ci @@ -0,0 +1,8 @@ +FROM alpine:3.20 +RUN apk --no-cache add ca-certificates && \ + adduser -D -u 1001 appuser +WORKDIR /app +COPY api . +USER appuser +EXPOSE 8080 +CMD ["./api"] From 6ee54ae1cae83f01cbb9e13a07246ed8446ba760 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:07:40 +0800 Subject: [PATCH 25/33] feat(ci): rewrite deploy workflow with OIDC, dev/prod branching, inline task def --- .github/workflows/deploy-backend.yml | 179 ++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 30 deletions(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 0379562..8373911 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -1,70 +1,181 @@ -name: Deploy Backend +name: Deploy Backend to AWS ECS on: push: - branches: [main] + branches: + - main + - develop paths: - "backend/**" - ".github/workflows/deploy-backend.yml" + workflow_dispatch: env: AWS_REGION: us-east-1 - ECR_REPOSITORY: kickwatch-backend - ECS_CLUSTER: kickwatch-cluster - ECS_SERVICE: kickwatch-backend-service - CONTAINER_NAME: kickwatch-backend jobs: - deploy: + build-and-deploy: + name: Build and Deploy runs-on: ubuntu-latest - defaults: - run: - working-directory: backend + permissions: + contents: read + id-token: write + + env: + IS_PROD: ${{ github.ref == 'refs/heads/main' }} + steps: + - name: Set environment variables + run: | + if [ "${{ env.IS_PROD }}" = "true" ]; then + echo "ECR_REPOSITORY=kickwatch-api" >> $GITHUB_ENV + echo "ECS_CLUSTER=kickwatch-cluster" >> $GITHUB_ENV + echo "ECS_SERVICE=kickwatch-api-service" >> $GITHUB_ENV + echo "CONTAINER_NAME=kickwatch-api" >> $GITHUB_ENV + echo "DEPLOY_ENV=production" >> $GITHUB_ENV + echo "SECRET_PREFIX=kickwatch" >> $GITHUB_ENV + echo "LOG_GROUP=/ecs/kickwatch-api" >> $GITHUB_ENV + echo "GIN_MODE=release" >> $GITHUB_ENV + else + echo "ECR_REPOSITORY=kickwatch-api-dev" >> $GITHUB_ENV + echo "ECS_CLUSTER=kickwatch-cluster-dev" >> $GITHUB_ENV + echo "ECS_SERVICE=kickwatch-api-dev-service" >> $GITHUB_ENV + echo "CONTAINER_NAME=kickwatch-api-dev" >> $GITHUB_ENV + echo "DEPLOY_ENV=development" >> $GITHUB_ENV + echo "SECRET_PREFIX=kickwatch-dev" >> $GITHUB_ENV + echo "LOG_GROUP=/ecs/kickwatch-api-dev" >> $GITHUB_ENV + echo "GIN_MODE=debug" >> $GITHUB_ENV + fi + - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - - run: go test ./... + - name: Run tests and vet + working-directory: backend + run: | + go vet ./... & + VET_PID=$! + go test ./... & + TEST_PID=$! + wait $VET_PID || exit 1 + wait $TEST_PID || exit 1 + + - name: Build Go binary + working-directory: backend + run: CGO_ENABLED=0 GOOS=linux go build -o api ./cmd/api - - name: Configure AWS credentials + - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - - name: Login to ECR + - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - - name: Build, tag, push image + - name: Ensure ECR repository exists + run: | + aws ecr describe-repositories --repository-names $ECR_REPOSITORY --region $AWS_REGION 2>/dev/null || \ + aws ecr create-repository --repository-name $ECR_REPOSITORY --region $AWS_REGION \ + --image-scanning-configuration scanOnPush=true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image id: build-image + uses: docker/build-push-action@v6 + with: + context: backend + file: backend/Dockerfile.ci + push: true + tags: | + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: Resolve Secrets Manager ARNs + id: secrets env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ github.sha }} + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + get_arn() { aws secretsmanager describe-secret --secret-id "$1" --region $AWS_REGION --query "ARN" --output text; } + echo "db_arn=$(get_arn ${SECRET_PREFIX}/database-url)" >> $GITHUB_OUTPUT + echo "apns_key_id_arn=$(get_arn ${SECRET_PREFIX}/apns-key-id)" >> $GITHUB_OUTPUT + echo "apns_team_id_arn=$(get_arn ${SECRET_PREFIX}/apns-team-id)" >> $GITHUB_OUTPUT + echo "apns_bundle_id_arn=$(get_arn ${SECRET_PREFIX}/apns-bundle-id)" >> $GITHUB_OUTPUT + echo "apns_key_arn=$(get_arn ${SECRET_PREFIX}/apns-key)" >> $GITHUB_OUTPUT - - name: Download ECS task definition + - name: Generate ECS task definition + env: + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} run: | - aws ecs describe-task-definition \ - --task-definition kickwatch-backend \ - --query taskDefinition \ - > task-definition.json + cat > /tmp/task-definition.json <> $GITHUB_STEP_SUMMARY + echo "- **Environment**: ${{ env.DEPLOY_ENV }}" >> $GITHUB_STEP_SUMMARY + echo "- **Cluster**: ${{ env.ECS_CLUSTER }}" >> $GITHUB_STEP_SUMMARY + echo "- **Service**: ${{ env.ECS_SERVICE }}" >> $GITHUB_STEP_SUMMARY + echo "- **Image**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY From efd37c720332f8bf036d17064f13e43d7c62900a Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:07:44 +0800 Subject: [PATCH 26/33] feat(ci): update test workflow to use go.mod version file and verify build --- .github/workflows/test-backend.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 088abf5..10ad409 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -4,9 +4,11 @@ on: push: paths: - "backend/**" + - ".github/workflows/test-backend.yml" pull_request: paths: - "backend/**" + - ".github/workflows/test-backend.yml" jobs: test: @@ -18,8 +20,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.24" - cache-dependency-path: backend/go.sum - - run: go build ./... - - run: go test ./... + go-version-file: go.mod + cache-dependency-path: go.sum - run: go vet ./... + - run: go test ./... + - run: CGO_ENABLED=0 GOOS=linux go build -o /dev/null ./cmd/api From bf707418bcabb77f5cbf750c662b0e82444dab78 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:11:23 +0800 Subject: [PATCH 27/33] fix(ci): set AWS_REGION to us-east-2 --- .github/workflows/deploy-backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 8373911..3244d5f 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: env: - AWS_REGION: us-east-1 + AWS_REGION: us-east-2 jobs: build-and-deploy: From b5fb48dcb6f032de4d7c2d114bf212f53236a8c6 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:26:10 +0800 Subject: [PATCH 28/33] docs: archive MVP implementation, app icon creation, and AWS infra setup --- .archive/2026-02-27/app-icon-creation.md | 62 ++++++++++ .archive/2026-02-27/aws-infra-setup.md | 112 ++++++++++++++++++ .archive/2026-02-27/mvp-implementation.md | 135 ++++++++++++++++++++++ .archive/MEMORY.md | 8 ++ 4 files changed, 317 insertions(+) create mode 100644 .archive/2026-02-27/app-icon-creation.md create mode 100644 .archive/2026-02-27/aws-infra-setup.md create mode 100644 .archive/2026-02-27/mvp-implementation.md diff --git a/.archive/2026-02-27/app-icon-creation.md b/.archive/2026-02-27/app-icon-creation.md new file mode 100644 index 0000000..31b225e --- /dev/null +++ b/.archive/2026-02-27/app-icon-creation.md @@ -0,0 +1,62 @@ +--- +date: 2026-02-27 +title: KickWatch App Icon Creation +category: design +tags: [app-icon, logo, svg, nanobanana, rsvg-convert, xcode, appiconset] +related: [2026-02-27/mvp-implementation.md] +--- + +# App Icon Creation + +## Concept + +Kickstarter K shape (rounded bubbly white K on green #05CE78) + newspaper/daily digest metaphor = daily monitor app identity. Notion-style: flat, no gradients, clean. + +## Process + +1. Generated 7 variations via nanobanana skill (Gemini image gen) +2. Selected `logo-07.png` — but user preferred a manually provided `o.png` +3. Pipeline on `o.png`: + - `crop_logo.py` → `final-cropped.png` (719×719 from 776×776) + - `remove_bg.py` → `final-nobg.png` (170KB, transparent bg via remove.bg API) + - `vectorize.py` → `final.svg` (10KB via Recraft API) + +## SVG Centering Fix + +The vectorized SVG had content off-center (bbox x:106–1983, y:285–1887 in 2000×2000 viewBox). Solution — wrap in centered group with white background: + +```svg + + + + +``` + +Content center: (1044.5, 1086). Scale 0.82 gives ~10% padding on all sides. + +## PNG Generation + +Used `rsvg-convert` (available via homebrew at `/opt/homebrew/bin/rsvg-convert`): + +```bash +for SIZE in 20 29 40 58 60 76 80 87 120 152 167 180 1024; do + rsvg-convert -w $SIZE -h $SIZE final-centered.svg -o AppIcon-${SIZE}x${SIZE}.png +done +cp AppIcon-1024x1024.png AppIcon.png +``` + +**cairosvg NOT usable** on this machine — cairo native library missing. Use `rsvg-convert` instead. + +## Contents.json + +Mirrors SnapAction's `AppIcon.appiconset/Contents.json` exactly (18 image entries for iPhone + iPad + ios-marketing). + +## Files + +All source assets in: +`.skill-archive/logo-creator/2026-02-27-kickwatch-logo/` +- `final-centered.svg` ← source of truth for icon +- `final-nobg.png`, `final-cropped.png`, `final.svg` + +Final PNGs committed to: +`ios/KickWatch/Assets.xcassets/AppIcon.appiconset/` diff --git a/.archive/2026-02-27/aws-infra-setup.md b/.archive/2026-02-27/aws-infra-setup.md new file mode 100644 index 0000000..719b753 --- /dev/null +++ b/.archive/2026-02-27/aws-infra-setup.md @@ -0,0 +1,112 @@ +--- +date: 2026-02-27 +title: KickWatch AWS Infrastructure Setup +category: infrastructure +tags: [aws, ecs, ecr, rds, iam, oidc, secrets-manager, github-actions] +related: [2026-02-27/mvp-implementation.md] +--- + +# KickWatch AWS Infrastructure Setup + +## Account & Region +- Account ID: `739654145647` +- Region: `us-east-2` +- IAM user: `snapaction-admin` (shared with SnapAction) + +## Resources Created + +### ECR Repositories +- `kickwatch-api` — prod +- `kickwatch-api-dev` — dev + +### IAM Roles +- `kickwatch-deploy-role` — GitHub Actions OIDC deploy role + - Trust: `repo:ReScienceLab/KickWatch:*` + - Policy: `kickwatch-deploy-policy` (ECR push, ECS deploy, iam:PassRole, secrets read) +- `kickwatch-task-role` — ECS container role (no extra permissions in v1) +- `ecsTaskExecutionRole` — existing shared role, added `kickwatch-secrets-access` inline policy + +### OIDC Provider +- Reused existing: `arn:aws:iam::739654145647:oidc-provider/token.actions.githubusercontent.com` + +### ECS Clusters +- `kickwatch-cluster` (prod, containerInsights=enabled) +- `kickwatch-cluster-dev` (dev, containerInsights=enabled) + +### ECS Services +- `kickwatch-cluster-dev/kickwatch-api-dev-service` (desired=0, task def :2) +- `kickwatch-cluster/kickwatch-api-service` (desired=0, task def :1) + +### ECS Task Definitions +- `kickwatch-api-dev:2` — dev, GIN_MODE=debug, APNS_ENV=sandbox +- `kickwatch-api:1` — prod, GIN_MODE=release, APNS_ENV=production +- Networking: awsvpc, subnets: `subnet-03c3f58cea867dac7`, `subnet-0eaf3dc3284bf18d9`, `subnet-0d6addfa05326637e` +- SG: `sg-09a8956d7d1e3274e` (default VPC SG) +- assignPublicIp: ENABLED + +### RDS +- `kickwatch-db-dev` — postgres 16.8, db.t3.micro, 20GB + - Endpoint: `kickwatch-db-dev.c164w44w2oh3.us-east-2.rds.amazonaws.com` + - DB name: `kickwatch_dev` + - User: `kickwatch` + - Password: stored in `/tmp/kw_dbpw_dev.txt` locally → set in Secrets Manager + - Publicly accessible: YES (needed for ECS task; protected by SG) + - SG: `sg-0f27ad8fd043ce974` (snapaction-rds-sg, allows 5432 from default SG) +- Prod RDS: **not yet created** — create when ready to deploy prod + +### CloudWatch Log Groups +- `/ecs/kickwatch-api` (30 day retention) +- `/ecs/kickwatch-api-dev` (14 day retention) + +### Secrets Manager +All in `us-east-2`: +| Secret | Value | +|--------|-------| +| `kickwatch-dev/database-url` | Real URL pointing to kickwatch-db-dev | +| `kickwatch-dev/apns-key-id` | `FILL_IN_APNS_KEY_ID` ← needs real value | +| `kickwatch-dev/apns-team-id` | `FILL_IN_APNS_TEAM_ID` ← needs real value | +| `kickwatch-dev/apns-bundle-id` | `com.kickwatch.app` | +| `kickwatch/database-url` | `PLACEHOLDER` ← fill when prod RDS created | +| `kickwatch/apns-key-id` | `FILL_IN_APNS_KEY_ID` ← needs real value | +| `kickwatch/apns-team-id` | `FILL_IN_APNS_TEAM_ID` ← needs real value | +| `kickwatch/apns-bundle-id` | `com.kickwatch.app` | + +### GitHub Secrets (ReScienceLab/KickWatch) +- `AWS_DEPLOY_ROLE_ARN` = `arn:aws:iam::739654145647:role/kickwatch-deploy-role` +- `AWS_ACCOUNT_ID` = `739654145647` + +## GitHub Actions Workflow +- `test-backend.yml` — triggers on `backend/**` changes, go vet + test + build +- `deploy-backend.yml` — OIDC auth, `develop`→dev deploy, `main`→prod deploy, Dockerfile.ci +- PR #2 open: `feature/ci-oidc → develop` + +## What Needs Manual Action +1. Fill APNs secrets: `APNS_KEY_ID`, `APNS_TEAM_ID` — from Apple Developer Portal +2. Upload `.p8` APNs key file — needs ECS secrets mount or embed in Secrets Manager +3. Create prod RDS (`kickwatch-db`) when ready to go to production +4. Set `DEVELOPMENT_TEAM` in `ios/project.yml` for Xcode builds +5. Set ECS service desired_count to 1 once first image is pushed to ECR + +## Gotchas +- VPN (Quantumult X) causes RDS DNS to resolve to 198.18.x.x — same issue as SnapAction + - Can't connect to RDS via psql from local machine when VPN active + - Fix: disable VPN, OR use ECS Exec from a running container +- `kickwatch-db-dev` shares SG `sg-0f27ad8fd043ce974` with SnapAction RDS + - SG already had port 5432 intra-SG rule (default SG → default SG) +- snapaction-db-dev was temporarily set to `publicly-accessible` during setup → reverted +- ECS services start at `desired_count=0` — CI deploy sets count to 1 on first deploy + +## Key Commands +```bash +# Update a secret +aws secretsmanager put-secret-value \ + --secret-id kickwatch-dev/apns-key-id \ + --region us-east-2 --secret-string "YOUR_KEY_ID" + +# Tail ECS logs +aws logs tail /ecs/kickwatch-api-dev --region us-east-2 --follow + +# Force new deployment +aws ecs update-service --cluster kickwatch-cluster-dev \ + --service kickwatch-api-dev-service --force-new-deployment --region us-east-2 +``` diff --git a/.archive/2026-02-27/mvp-implementation.md b/.archive/2026-02-27/mvp-implementation.md new file mode 100644 index 0000000..a593e1b --- /dev/null +++ b/.archive/2026-02-27/mvp-implementation.md @@ -0,0 +1,135 @@ +--- +date: 2026-02-27 +title: KickWatch MVP Implementation +category: feature +tags: [go, gin, gorm, swiftui, swiftdata, kickstarter, graphql, apns, cron, xcodegen] +--- + +# KickWatch MVP Implementation + +## What Was Built + +Full MVP from scratch across 22+ atomic commits on `develop` branch. + +### Backend (Go/Gin) — `backend/` + +**Module**: `github.com/kickwatch/backend` + +**Key packages added**: +``` +github.com/gin-gonic/gin +github.com/joho/godotenv +github.com/google/uuid +github.com/golang-jwt/jwt/v5 +github.com/robfig/cron/v3 +gorm.io/gorm +gorm.io/driver/postgres +``` + +**Structure**: +- `internal/config/` — env var loader +- `internal/model/` — GORM models: Campaign, Category, Device, Alert, AlertMatch +- `internal/db/` — AutoMigrate on startup +- `internal/middleware/` — CORS, Logger +- `internal/service/kickstarter_rest.go` — REST /discover/advanced.json (no auth, nightly crawl) +- `internal/service/kickstarter_graph.go` — GraphQL /graph with session bootstrap (CSRF + _ksr_session cookie), 12h refresh, 403 retry +- `internal/service/apns.go` — APNs HTTP/2 with JWT signing (golang-jwt ES256) +- `internal/service/cron.go` — nightly 02:00 UTC crawl, 15 categories × 10 pages, upsert + alert matching + APNs push +- `internal/handler/` — campaigns, search, categories, devices, alerts CRUD + +**API routes**: +``` +GET /api/health +GET /api/campaigns?sort=trending|newest|ending&category_id=&cursor=&limit= +GET /api/campaigns/search?q=&category_id=&cursor= +GET /api/campaigns/:pid +GET /api/categories +POST /api/devices/register +POST /api/alerts +GET /api/alerts?device_id= +PATCH /api/alerts/:id +DELETE /api/alerts/:id +GET /api/alerts/:id/matches +``` + +**Gotcha**: `restProject` struct had duplicate json tag `"urls"` on two fields — caused `go vet` failure. Fixed by removing the unused `URL string` field. + +### iOS (SwiftUI/SwiftData) — `ios/` + +**project.yml** key settings: +```yaml +bundleIdPrefix: com.kickwatch +deploymentTarget: iOS: "17.0" +PRODUCT_BUNDLE_IDENTIFIER: com.kickwatch.app +DEVELOPMENT_TEAM: "" # fill in before building +``` + +**SwiftData models**: Campaign, WatchlistAlert, RecentSearch + +**Services**: +- `APIClient` — actor, base URL switches DEBUG/Release, supports GET/POST/PATCH/DELETE +- `KeychainHelper` — identical pattern to SnapAction +- `NotificationService` — @MainActor ObservableObject, registers APNs token via APIClient, stores device_id in Keychain +- `ImageCache` — actor-based URL→Image cache with RemoteImage SwiftUI view + +**ViewModels**: `@Observable` (iOS 17 pattern, not ObservableObject) +- `DiscoverViewModel` — sort + category filter + cursor pagination +- `AlertsViewModel` — full CRUD + +**Views**: DiscoverView, CampaignRowView, CampaignDetailView (funding ring), WatchlistView, AlertsView, AlertMatchesView, SearchView, SettingsView, CategoryChip + +**App entry**: `KickWatchApp` with `@UIApplicationDelegateAdaptor(AppDelegate.self)` for APNs token registration + +### CI/CD +- `.github/workflows/test-backend.yml` — triggered on `backend/**` changes, runs `go build`, `go test`, `go vet` +- `.github/workflows/deploy-backend.yml` — triggered on `main` push, builds Docker image, pushes to ECR, deploys to ECS + +## Git Workflow + +- Worktree created at `.worktrees/develop` for `develop` branch +- `.worktrees/` added to `.gitignore` before creation +- All work on `develop`, never touched `main` directly +- Published repo: https://github.com/ReScienceLab/KickWatch + +## .gitignore Fix + +Original pattern `*.env` blocked `.env.example`. Changed to: +``` +.env +.env.local +.env.production +!.env.example +``` + +## App Icon + +- Generated via Gemini (nanobanana skill): Kickstarter K + newspaper/daily digest metaphor, Notion-style flat design, green (#05CE78) +- Best result: `logo-07.png` → user provided `o.png` as final source +- Processed: crop → remove_bg (remove.bg API) → vectorize (Recraft API) → SVG +- Centered in white background SVG: `final-centered.svg` + - Transform: `translate(1000,1000) scale(0.82) translate(-1044.5,-1086)` (content bbox: x 106–1983, y 285–1887) +- All 14 PNG sizes generated with `rsvg-convert` (homebrew): + ```bash + rsvg-convert -w $SIZE -h $SIZE input.svg -o AppIcon-${SIZE}x${SIZE}.png + ``` + Sizes: 20, 29, 40, 58, 60, 76, 80, 87, 120, 152, 167, 180, 1024 + +## Commands Reference + +```bash +# Backend +cd backend && go run ./cmd/api +cd backend && go test ./... +cd backend && go build ./... && go vet ./... + +# iOS +cd ios && xcodegen generate +xcodebuild -project ios/KickWatch.xcodeproj -scheme KickWatch build + +# Worktree +git worktree add .worktrees/develop -b develop +cd .worktrees/develop + +# Icon generation +rsvg-convert -w 1024 -h 1024 final-centered.svg -o AppIcon-1024x1024.png +``` diff --git a/.archive/MEMORY.md b/.archive/MEMORY.md index fb9b64c..c833c82 100644 --- a/.archive/MEMORY.md +++ b/.archive/MEMORY.md @@ -4,9 +4,17 @@ Archived learnings, debugging solutions, and infrastructure notes. Search: `grep -ri "keyword" .archive/` ## Infrastructure & AWS +- `2026-02-27/aws-infra-setup.md` — Full AWS setup: ECR (kickwatch-api/-dev), IAM OIDC deploy role, ECS clusters+services (desired=0), kickwatch-db-dev RDS (postgres 16.8, t3.micro, us-east-2), 8 Secrets Manager entries, GitHub secrets. **Pending**: fill APNs secrets, create prod RDS, set ECS desired_count=1 after first ECR push. **Gotcha**: VPN breaks local→RDS psql (same as SnapAction). ## Release & Deploy +- `2026-02-27/mvp-implementation.md` — Full MVP build: Go backend + iOS app, git workflow, CI/CD, repo published to ReScienceLab/KickWatch ## Debugging & Fixes +- `2026-02-27/mvp-implementation.md` — .gitignore blocked `.env.example` (fix: replace `*.env` with explicit patterns + `!.env.example`); `go vet` failed on duplicate json tag in restProject struct ## Features +- `2026-02-27/mvp-implementation.md` — Backend: Kickstarter REST+GraphQL clients, APNs, nightly cron, full REST API. iOS: SwiftData models, APIClient actor, all 4 tabs, cursor pagination +- `2026-02-27/mvp-implementation.md` — CI/CD: GitHub Actions test + ECS deploy workflows + +## Design +- `2026-02-27/app-icon-creation.md` — App icon: K + newspaper concept, Notion-style. SVG centering transform, rsvg-convert for all 14 PNG sizes (cairosvg broken on this machine — use rsvg-convert) From 76c2dcce032d081d38b9267c7064e00fe50e8536 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:28:29 +0800 Subject: [PATCH 29/33] feat(ios): set DEVELOPMENT_TEAM, add APNs entitlements --- ios/KickWatch/KickWatch.entitlements | 8 ++++++++ ios/project.yml | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 ios/KickWatch/KickWatch.entitlements diff --git a/ios/KickWatch/KickWatch.entitlements b/ios/KickWatch/KickWatch.entitlements new file mode 100644 index 0000000..2cf0210 --- /dev/null +++ b/ios/KickWatch/KickWatch.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/ios/project.yml b/ios/project.yml index a2589b5..4b7544f 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -25,10 +25,12 @@ targets: NSUserNotificationsUsageDescription: "KickWatch sends daily digests when new campaigns match your keyword alerts." UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait + entitlements: + path: KickWatch/KickWatch.entitlements settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.kickwatch.app INFOPLIST_FILE: KickWatch/Info.plist CODE_SIGN_STYLE: Automatic - DEVELOPMENT_TEAM: "" + DEVELOPMENT_TEAM: 7Q28CBP3S5 ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon From fce4574a9e1cdecc8a71fd3c795559522bd4fa75 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:33:45 +0800 Subject: [PATCH 30/33] fix(ios): update bundle ID to com.rescience.kickwatch --- ios/project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/project.yml b/ios/project.yml index 4b7544f..937fc58 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -29,7 +29,7 @@ targets: path: KickWatch/KickWatch.entitlements settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.kickwatch.app + PRODUCT_BUNDLE_IDENTIFIER: com.rescience.kickwatch INFOPLIST_FILE: KickWatch/Info.plist CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: 7Q28CBP3S5 From 5d2890305a481b0b55f8e143ca0e5f3a6347db14 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:35:34 +0800 Subject: [PATCH 31/33] feat(backend): read APNs key from APNS_KEY env var (Secrets Manager) instead of file path --- backend/internal/config/config.go | 2 ++ backend/internal/service/apns.go | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 870437d..0fcb121 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,6 +9,7 @@ type Config struct { APNSTeamID string APNSBundleID string APNSKeyPath string + APNSKey string APNSEnv string } @@ -28,6 +29,7 @@ func Load() *Config { APNSTeamID: os.Getenv("APNS_TEAM_ID"), APNSBundleID: os.Getenv("APNS_BUNDLE_ID"), APNSKeyPath: os.Getenv("APNS_KEY_PATH"), + APNSKey: os.Getenv("APNS_KEY"), APNSEnv: apnsEnv, } } diff --git a/backend/internal/service/apns.go b/backend/internal/service/apns.go index 68a8c7f..c4117ce 100644 --- a/backend/internal/service/apns.go +++ b/backend/internal/service/apns.go @@ -26,9 +26,17 @@ type APNsClient struct { } func NewAPNsClient(cfg *config.Config) (*APNsClient, error) { - keyData, err := os.ReadFile(cfg.APNSKeyPath) - if err != nil { - return nil, fmt.Errorf("read apns key: %w", err) + var keyData []byte + var err error + if cfg.APNSKey != "" { + keyData = []byte(cfg.APNSKey) + } else if cfg.APNSKeyPath != "" { + keyData, err = os.ReadFile(cfg.APNSKeyPath) + if err != nil { + return nil, fmt.Errorf("read apns key: %w", err) + } + } else { + return nil, fmt.Errorf("APNS_KEY or APNS_KEY_PATH must be set") } block, _ := pem.Decode(keyData) if block == nil { From 6914ea0c9c287c8d8a18bb668ad456fe07ddf809 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:35:34 +0800 Subject: [PATCH 32/33] feat(ci): inject APNS_KEY from Secrets Manager into ECS task --- .github/workflows/deploy-backend.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 3244d5f..cc0174e 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -138,14 +138,14 @@ jobs: { "name": "PORT", "value": "8080" }, { "name": "GIN_MODE", "value": "${{ env.GIN_MODE }}" }, { "name": "APP_ENV", "value": "${{ env.DEPLOY_ENV }}" }, - { "name": "APNS_ENV", "value": "${{ env.IS_PROD == 'true' && 'production' || 'sandbox' }}" }, - { "name": "APNS_KEY_PATH","value": "/secrets/apns.p8" } + { "name": "APNS_ENV", "value": "${{ env.IS_PROD == 'true' && 'production' || 'sandbox' }}" } ], "secrets": [ { "name": "DATABASE_URL", "valueFrom": "${{ steps.secrets.outputs.db_arn }}" }, { "name": "APNS_KEY_ID", "valueFrom": "${{ steps.secrets.outputs.apns_key_id_arn }}" }, { "name": "APNS_TEAM_ID", "valueFrom": "${{ steps.secrets.outputs.apns_team_id_arn }}" }, - { "name": "APNS_BUNDLE_ID", "valueFrom": "${{ steps.secrets.outputs.apns_bundle_id_arn }}" } + { "name": "APNS_BUNDLE_ID", "valueFrom": "${{ steps.secrets.outputs.apns_bundle_id_arn }}" }, + { "name": "APNS_KEY", "valueFrom": "${{ steps.secrets.outputs.apns_key_arn }}" } ], "readonlyRootFilesystem": true, "linuxParameters": { "initProcessEnabled": true }, From 39893cf771c06f775bb4cd3ee780565c0a04c976 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 13:36:52 +0800 Subject: [PATCH 33/33] docs: archive APNs setup (key GUFRSCY8ZV, bundle com.rescience.kickwatch) --- .archive/2026-02-27/apns-setup.md | 72 +++++++++++++++++++++++++++++++ .archive/MEMORY.md | 3 +- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 .archive/2026-02-27/apns-setup.md diff --git a/.archive/2026-02-27/apns-setup.md b/.archive/2026-02-27/apns-setup.md new file mode 100644 index 0000000..20ff8be --- /dev/null +++ b/.archive/2026-02-27/apns-setup.md @@ -0,0 +1,72 @@ +--- +date: 2026-02-27 +title: APNs Key Setup and CI/CD Integration +category: infrastructure +tags: [apns, ios, push-notifications, secrets-manager, ecs, github-actions] +related: [2026-02-27/aws-infra-setup.md] +--- + +# APNs Key Setup and CI/CD Integration + +## Apple Developer Portal + +- **Key Name**: KickWatch APNs +- **Key ID**: `GUFRSCY8ZV` +- **Team ID**: `7Q28CBP3S5` (same as SnapAction) +- **Bundle ID**: `com.rescience.kickwatch` +- **Environment**: Sandbox & Production (covers both dev and prod with one key) +- **Key Restriction**: Team Scoped (All Topics) +- **File**: `AuthKey_GUFRSCY8ZV.p8` — downloaded to `/Users/yilin/Downloads/` + +## Secrets Manager (us-east-2) + +All 4 APNs secrets set for both dev and prod prefixes: + +| Secret | Value | +|--------|-------| +| `kickwatch-dev/apns-key-id` | `GUFRSCY8ZV` | +| `kickwatch-dev/apns-team-id` | `7Q28CBP3S5` | +| `kickwatch-dev/apns-bundle-id` | `com.rescience.kickwatch` | +| `kickwatch-dev/apns-key` | Full `.p8` PEM content | +| `kickwatch/apns-key-id` | `GUFRSCY8ZV` | +| `kickwatch/apns-team-id` | `7Q28CBP3S5` | +| `kickwatch/apns-bundle-id` | `com.rescience.kickwatch` | +| `kickwatch/apns-key` | Full `.p8` PEM content | + +## Commands Used + +```bash +KEY_ID="GUFRSCY8ZV" +REGION=us-east-2 + +# Key ID +aws secretsmanager put-secret-value \ + --secret-id kickwatch-dev/apns-key-id --region $REGION --secret-string "$KEY_ID" + +# .p8 content +aws secretsmanager put-secret-value \ + --secret-id kickwatch-dev/apns-key --region $REGION \ + --secret-string "$(cat ~/Downloads/AuthKey_GUFRSCY8ZV.p8)" +``` + +## Backend Change: File Path → Env Var + +`internal/service/apns.go` updated to read key from `APNS_KEY` env var first, falling back to `APNS_KEY_PATH` file. Avoids need to mount `.p8` file into ECS container. + +`internal/config/config.go` added `APNSKey string` field reading `APNS_KEY`. + +## CI Workflow Change + +`deploy-backend.yml` — removed `APNS_KEY_PATH` env var, added `APNS_KEY` secret injected from Secrets Manager ARN. + +## iOS Changes + +- `project.yml`: `DEVELOPMENT_TEAM: 7Q28CBP3S5`, `PRODUCT_BUNDLE_IDENTIFIER: com.rescience.kickwatch` +- `KickWatch.entitlements`: `aps-environment = development` + +## Gotchas + +- APNs key environment set to **Sandbox & Production** — one key works for both; do NOT create separate keys +- Bundle ID must match exactly what's registered in Apple Developer Portal +- `APNS_KEY` env var content is the raw PEM string including `-----BEGIN PRIVATE KEY-----` header/footer +- ECS task execution role needs `secretsmanager:GetSecretValue` for `kickwatch*` ARNs (already added) diff --git a/.archive/MEMORY.md b/.archive/MEMORY.md index c833c82..9471b6c 100644 --- a/.archive/MEMORY.md +++ b/.archive/MEMORY.md @@ -4,7 +4,8 @@ Archived learnings, debugging solutions, and infrastructure notes. Search: `grep -ri "keyword" .archive/` ## Infrastructure & AWS -- `2026-02-27/aws-infra-setup.md` — Full AWS setup: ECR (kickwatch-api/-dev), IAM OIDC deploy role, ECS clusters+services (desired=0), kickwatch-db-dev RDS (postgres 16.8, t3.micro, us-east-2), 8 Secrets Manager entries, GitHub secrets. **Pending**: fill APNs secrets, create prod RDS, set ECS desired_count=1 after first ECR push. **Gotcha**: VPN breaks local→RDS psql (same as SnapAction). +- `2026-02-27/aws-infra-setup.md` — Full AWS setup: ECR (kickwatch-api/-dev), IAM OIDC deploy role, ECS clusters+services (desired=0), kickwatch-db-dev RDS (postgres 16.8, t3.micro, us-east-2), 8 Secrets Manager entries, GitHub secrets. **Pending**: create prod RDS, set ECS desired_count=1 after first ECR push. **Gotcha**: VPN breaks local→RDS psql (same as SnapAction). +- `2026-02-27/apns-setup.md` — APNs key `GUFRSCY8ZV`, team `7Q28CBP3S5`, bundle `com.rescience.kickwatch`, Sandbox+Production env. All 8 secrets set. Backend reads APNS_KEY from env var (not file). CI injects via Secrets Manager. ## Release & Deploy - `2026-02-27/mvp-implementation.md` — Full MVP build: Go backend + iOS app, git workflow, CI/CD, repo published to ReScienceLab/KickWatch