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/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..9471b6c 100644 --- a/.archive/MEMORY.md +++ b/.archive/MEMORY.md @@ -4,9 +4,18 @@ 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**: 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 ## 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) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..cc0174e --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,194 @@ +name: Deploy Backend to AWS ECS + +on: + push: + branches: + - main + - develop + paths: + - "backend/**" + - ".github/workflows/deploy-backend.yml" + workflow_dispatch: + +env: + AWS_REGION: us-east-2 + +jobs: + build-and-deploy: + name: Build and Deploy + runs-on: ubuntu-latest + 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-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - 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 (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - 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: + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + run: | + 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: Generate ECS task definition + env: + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + run: | + 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 diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 0000000..10ad409 --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,27 @@ +name: Test Backend + +on: + push: + paths: + - "backend/**" + - ".github/workflows/test-backend.yml" + pull_request: + paths: + - "backend/**" + - ".github/workflows/test-backend.yml" + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + 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 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 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"] 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"] 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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..0fcb121 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,35 @@ +package config + +import "os" + +type Config struct { + DatabaseURL string + Port string + APNSKeyID string + APNSTeamID string + APNSBundleID string + APNSKeyPath string + APNSKey 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"), + APNSKey: os.Getenv("APNS_KEY"), + 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/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"}) +} 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)) + } +} 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 +} diff --git a/backend/internal/service/apns.go b/backend/internal/service/apns.go new file mode 100644 index 0000000..c4117ce --- /dev/null +++ b/backend/internal/service/apns.go @@ -0,0 +1,172 @@ +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) { + 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 { + 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 +} 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) + } + } +} 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 +} diff --git a/backend/internal/service/kickstarter_rest.go b/backend/internal/service/kickstarter_rest.go new file mode 100644 index 0000000..d4795bc --- /dev/null +++ b/backend/internal/service/kickstarter_rest.go @@ -0,0 +1,116 @@ +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"` + 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 +} 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/AppIcon-1024x1024.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png new file mode 100644 index 0000000..6251057 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png differ diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png new file mode 100644 index 0000000..41cc4f9 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png differ 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 0000000..d714bee Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png differ 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 0000000..225fd42 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png differ 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 0000000..d0a98f4 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png differ diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png new file mode 100644 index 0000000..696f16c Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png differ 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 0000000..a1aac0e Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png differ diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png new file mode 100644 index 0000000..5867768 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png differ 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 0000000..6a14a5e Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png differ diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png new file mode 100644 index 0000000..4011590 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png differ 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 0000000..a8917bb Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png differ diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png new file mode 100644 index 0000000..361570f Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png differ 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 0000000..38ca7d1 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png differ 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 0000000..6251057 Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg new file mode 100644 index 0000000..7d5027f --- /dev/null +++ b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + 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..10c27ca --- /dev/null +++ b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "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" + } + ], + "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/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/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) + } +} 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 + } +} 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) + } +} 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() + } +} 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 + } + } +} 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() + } +} 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 + } +} 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() } + } + } +} 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 + } + } +} 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..937fc58 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,36 @@ +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 + entitlements: + path: KickWatch/KickWatch.entitlements + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.rescience.kickwatch + INFOPLIST_FILE: KickWatch/Info.plist + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: 7Q28CBP3S5 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon