Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
05f10d4
feat: scaffold Go project with Docker Compose setup
NSchatz Apr 1, 2026
1b3b5bc
fix: Dockerfile COPY pattern and align JWT_SECRET placeholder
NSchatz Apr 1, 2026
efae4f0
feat: database schema with PostGIS and auto-migration runner
NSchatz Apr 1, 2026
5fb938e
feat: shared model types for users, circles, locations, geofences
NSchatz Apr 1, 2026
b598f25
feat: store layer for users and circles with integration tests
NSchatz Apr 1, 2026
d63b96d
feat: auth package with JWT, bcrypt, and HTTP middleware
NSchatz Apr 1, 2026
f1dc03a
feat: API router with register and login endpoints
NSchatz Apr 1, 2026
d91b842
feat: store layer for location insert, latest, history, and retention
NSchatz Apr 1, 2026
889f204
feat: location ingestion and query API endpoints
NSchatz Apr 1, 2026
c88e615
feat: WebSocket hub for real-time location broadcast
NSchatz Apr 1, 2026
9f31773
feat: circle CRUD API endpoints
NSchatz Apr 1, 2026
0e523c2
feat: geofence store with PostGIS spatial queries
NSchatz Apr 1, 2026
4d247ed
feat: geofence CRUD API endpoints
NSchatz Apr 1, 2026
0a99f24
feat: in-memory geofence state tracker with enter/leave detection
NSchatz Apr 1, 2026
3888ccf
feat: FCM notification package with sender interface
NSchatz Apr 1, 2026
97d856d
feat: FCM sender implementation with noop fallback
NSchatz Apr 1, 2026
5cffbd2
feat: wire all components in main.go with graceful shutdown and reten…
NSchatz Apr 1, 2026
90c9092
feat: wire geofence evaluation and WS broadcast into location ingestion
NSchatz Apr 1, 2026
e6ee841
feat: FCM token registration and geofence notification delivery
NSchatz Apr 1, 2026
357e9a4
fix: Dockerfile Go version, migration conflict, and Docker Compose port
NSchatz Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
DATABASE_URL=postgres://tracker:tracker@localhost:5432/tracker?sslmode=disable
JWT_SECRET=dev-secret-change-me
FCM_CREDENTIALS_FILE=
LOCATION_RETENTION_DAYS=30
WS_PING_INTERVAL=30s
PORT=8080
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
.worktrees/
.superpowers/
.env
*.exe
/server/tracker
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY server/go.mod ./
COPY server/go.sum* ./
RUN go mod download
COPY server/ .
RUN CGO_ENABLED=0 go build -o /tracker ./cmd/tracker

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /tracker /usr/local/bin/tracker
ENTRYPOINT ["tracker"]
33 changes: 33 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
services:
postgres:
image: postgis/postgis:16-3.4
environment:
POSTGRES_USER: tracker
POSTGRES_PASSWORD: tracker
POSTGRES_DB: tracker
ports:
- "5434:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tracker"]
interval: 5s
timeout: 3s
retries: 5

tracker-server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://tracker:tracker@postgres:5432/tracker?sslmode=disable
JWT_SECRET: dev-secret-change-me
PORT: "8080"
depends_on:
postgres:
condition: service_healthy

volumes:
pgdata:
118 changes: 118 additions & 0 deletions server/cmd/tracker/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"

"github.com/nschatz/tracker/server/internal/api"
"github.com/nschatz/tracker/server/internal/auth"
"github.com/nschatz/tracker/server/internal/geo"
"github.com/nschatz/tracker/server/internal/notify"
"github.com/nschatz/tracker/server/internal/store"
"github.com/nschatz/tracker/server/internal/ws"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

port := envOrDefault("PORT", "8080")
dbURL := requireEnv("DATABASE_URL")
jwtSecret := requireEnv("JWT_SECRET")
fcmCreds := os.Getenv("FCM_CREDENTIALS_FILE")
retentionDays := envIntOrDefault("LOCATION_RETENTION_DAYS", 30)

db, err := store.New(ctx, dbURL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer db.Close()

a := auth.New(jwtSecret)
hub := ws.NewHub()
go hub.Run()

var sender notify.Sender
if fcmCreds != "" {
s, err := notify.NewFCMSender(ctx, fcmCreds)
if err != nil {
log.Fatalf("fcm: %v", err)
}
sender = s
} else {
log.Println("WARNING: FCM_CREDENTIALS_FILE not set, using noop sender")
sender = notify.NoopSender{}
}
notifier := notify.NewNotifier(sender)
geoTracker := geo.NewTracker()

srv := api.NewServer(a, db, db, db, db, hub, geoTracker, notifier, db, db)

go runRetention(ctx, db, retentionDays)

httpSrv := &http.Server{Addr: ":" + port, Handler: srv}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpSrv.Shutdown(shutdownCtx)
}()

log.Printf("listening on :%s", port)
if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("http: %v", err)
}
}

func runRetention(ctx context.Context, db interface {
DeleteLocationsOlderThan(context.Context, int) (int64, error)
}, days int) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
count, err := db.DeleteLocationsOlderThan(ctx, days)
if err != nil {
log.Printf("retention: %v", err)
} else if count > 0 {
log.Printf("retention: deleted %d old location rows", count)
}
}
}
}

func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

func requireEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatalf("required env var %s is not set", key)
}
return v
}

func envIntOrDefault(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("env var %s must be an integer: %v", key, err)
}
return n
}
68 changes: 68 additions & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
module github.com/nschatz/tracker/server

go 1.25.0

require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/firestore v1.21.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/longrunning v0.8.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
cloud.google.com/go/storage v1.56.0 // indirect
firebase.google.com/go/v4 v4.19.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.19.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.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.273.1 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
nhooyr.io/websocket v1.8.17 // indirect
)
Loading
Loading