From 871a3313abadfe92340c4c69ece4857aa0192813 Mon Sep 17 00:00:00 2001 From: David Black Date: Fri, 12 Jun 2026 19:09:10 +0000 Subject: [PATCH] feat(activity-watcher): add phase-1 backend MVP service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the activity-watcher service: POST /events ingest with full validation, GET /users/{id}/events retrieval (newest first), GET /health liveness probe, SQLite-backed persistence (WAL mode, pure-Go modernc driver), layered Handler→Service→Repository architecture with extracted interface for Phase-2 Postgres swap. Includes multi-stage Dockerfile (CGO_ENABLED=0), OpenAPI 3.0 spec, smoke test script, design doc, and integration test suite covering all 7 acceptance criteria. Co-Authored-By: Claude Sonnet 4.6 --- api/activity-watcher-openapi.yaml | 134 +++++++++++++ cmd/activity-watcher/Dockerfile | 17 ++ cmd/activity-watcher/helpers_test.go | 10 + cmd/activity-watcher/main.go | 89 +++++++++ cmd/activity-watcher/main_test.go | 176 ++++++++++++++++++ cmd/activity-watcher/smoke.sh | 70 +++++++ .../activity-watcher-phase1/design.md | 91 +++++++++ go.mod | 6 + go.sum | 13 ++ internal/activitywatcher/handler/events.go | 64 +++++++ internal/activitywatcher/handler/health.go | 19 ++ internal/activitywatcher/model/event.go | 64 +++++++ .../activitywatcher/repository/interface.go | 16 ++ internal/activitywatcher/repository/sqlite.go | 125 +++++++++++++ internal/activitywatcher/service/events.go | 47 +++++ 15 files changed, 941 insertions(+) create mode 100644 api/activity-watcher-openapi.yaml create mode 100644 cmd/activity-watcher/Dockerfile create mode 100644 cmd/activity-watcher/helpers_test.go create mode 100644 cmd/activity-watcher/main.go create mode 100644 cmd/activity-watcher/main_test.go create mode 100755 cmd/activity-watcher/smoke.sh create mode 100644 docs/design/2026-06-12/activity-watcher-phase1/design.md create mode 100644 internal/activitywatcher/handler/events.go create mode 100644 internal/activitywatcher/handler/health.go create mode 100644 internal/activitywatcher/model/event.go create mode 100644 internal/activitywatcher/repository/interface.go create mode 100644 internal/activitywatcher/repository/sqlite.go create mode 100644 internal/activitywatcher/service/events.go diff --git a/api/activity-watcher-openapi.yaml b/api/activity-watcher-openapi.yaml new file mode 100644 index 00000000..b96823c0 --- /dev/null +++ b/api/activity-watcher-openapi.yaml @@ -0,0 +1,134 @@ +openapi: "3.0.3" +info: + title: Activity Watcher API + version: "1.0.0-phase1" + description: Ingest and query user activity events. Phase 1 backend MVP. + +paths: + /health: + get: + summary: Liveness probe + operationId: getHealth + responses: + "200": + description: Service is alive + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + + /events: + post: + summary: Ingest an activity event + operationId: createEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventInput' + responses: + "201": + description: Event created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EventCreated' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /users/{user_id}/events: + get: + summary: Retrieve events for a user, newest first + operationId: listUserEvents + parameters: + - name: user_id + in: path + required: true + schema: + type: string + responses: + "200": + description: List of events (may be empty) + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + "500": + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + EventInput: + type: object + required: [user_id, event_type, occurred_at, metadata] + properties: + user_id: + type: string + minLength: 1 + maxLength: 255 + example: user-abc123 + event_type: + type: string + minLength: 1 + maxLength: 255 + example: page_view + occurred_at: + type: string + format: date-time + description: Client-supplied event timestamp (RFC 3339). Must not be in the future or more than 30 days old. + example: "2026-06-12T10:00:00Z" + metadata: + type: object + description: Free-form JSON object with event-specific data. + example: + page: /home + + EventCreated: + type: object + properties: + id: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + + Event: + type: object + properties: + id: + type: string + format: uuid + user_id: + type: string + event_type: + type: string + occurred_at: + type: string + format: date-time + metadata: + type: object + created_at: + type: string + format: date-time + description: Server-stamped ingestion timestamp. + + Error: + type: object + properties: + error: + type: string + example: "user_id is required" diff --git a/cmd/activity-watcher/Dockerfile b/cmd/activity-watcher/Dockerfile new file mode 100644 index 00000000..45a411ca --- /dev/null +++ b/cmd/activity-watcher/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /activity-watcher ./cmd/activity-watcher + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /activity-watcher . +VOLUME ["/data"] +ENV DB_PATH=/data/events.db +ENV PORT=8080 +ENV LOG_LEVEL=info +EXPOSE 8080 +ENTRYPOINT ["/app/activity-watcher"] diff --git a/cmd/activity-watcher/helpers_test.go b/cmd/activity-watcher/helpers_test.go new file mode 100644 index 00000000..f567c762 --- /dev/null +++ b/cmd/activity-watcher/helpers_test.go @@ -0,0 +1,10 @@ +package main + +import ( + "io" + "log/slog" +) + +func newTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/cmd/activity-watcher/main.go b/cmd/activity-watcher/main.go new file mode 100644 index 00000000..57847f3d --- /dev/null +++ b/cmd/activity-watcher/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/simple-container-com/api/internal/activitywatcher/handler" + "github.com/simple-container-com/api/internal/activitywatcher/repository" + "github.com/simple-container-com/api/internal/activitywatcher/service" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel(), + })) + slog.SetDefault(logger) + + dbPath := envOr("DB_PATH", "/data/events.db") + port := envOr("PORT", "8080") + + repo, err := repository.NewSQLiteRepository(dbPath) + if err != nil { + logger.Error("failed to open database", "path", dbPath, "error", err) + os.Exit(1) + } + defer repo.Close() + + svc := service.NewEventService(repo) + evtHandler := handler.NewEventHandler(svc, logger) + healthHandler := handler.NewHealthHandler() + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", healthHandler.Health) + mux.HandleFunc("POST /events", evtHandler.Create) + mux.HandleFunc("GET /users/{user_id}/events", evtHandler.ListByUser) + + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + go func() { + logger.Info("activity-watcher starting", "port", port, "db", dbPath) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("server error", "error", err) + os.Exit(1) + } + }() + + <-quit + logger.Info("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + logger.Error("shutdown error", "error", err) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func logLevel() slog.Level { + switch os.Getenv("LOG_LEVEL") { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/cmd/activity-watcher/main_test.go b/cmd/activity-watcher/main_test.go new file mode 100644 index 00000000..20874cee --- /dev/null +++ b/cmd/activity-watcher/main_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + . "github.com/onsi/gomega" + + "github.com/simple-container-com/api/internal/activitywatcher/handler" + "github.com/simple-container-com/api/internal/activitywatcher/repository" + "github.com/simple-container-com/api/internal/activitywatcher/service" +) + +func newTestServer(t *testing.T) http.Handler { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "test-events-*.db") + if err != nil { + t.Fatalf("create temp db: %v", err) + } + f.Close() + + repo, err := repository.NewSQLiteRepository(f.Name()) + if err != nil { + t.Fatalf("open repo: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + + svc := service.NewEventService(repo) + evtH := handler.NewEventHandler(svc, newTestLogger()) + healthH := handler.NewHealthHandler() + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", healthH.Health) + mux.HandleFunc("POST /events", evtH.Create) + mux.HandleFunc("GET /users/{user_id}/events", evtH.ListByUser) + return mux +} + +func TestHealth(t *testing.T) { + RegisterTestingT(t) + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var body map[string]string + Expect(json.NewDecoder(rec.Body).Decode(&body)).To(Succeed()) + Expect(body["status"]).To(Equal("ok")) +} + +func TestCreateEvent_Valid(t *testing.T) { + RegisterTestingT(t) + srv := newTestServer(t) + + payload := map[string]interface{}{ + "user_id": "user-1", + "event_type": "page_view", + "occurred_at": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), + "metadata": map[string]string{"page": "/home"}, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/events", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusCreated)) + var resp map[string]string + Expect(json.NewDecoder(rec.Body).Decode(&resp)).To(Succeed()) + Expect(resp["id"]).ToNot(BeEmpty()) +} + +func TestCreateEvent_MissingFields(t *testing.T) { + RegisterTestingT(t) + srv := newTestServer(t) + + tests := []struct { + name string + payload map[string]interface{} + }{ + {"empty body", map[string]interface{}{}}, + {"missing user_id", map[string]interface{}{ + "event_type": "click", + "occurred_at": time.Now().Add(-time.Minute).Format(time.RFC3339), + "metadata": map[string]string{}, + }}, + {"missing event_type", map[string]interface{}{ + "user_id": "u1", + "occurred_at": time.Now().Add(-time.Minute).Format(time.RFC3339), + "metadata": map[string]string{}, + }}, + {"future occurred_at", map[string]interface{}{ + "user_id": "u1", + "event_type": "click", + "occurred_at": time.Now().Add(time.Hour).Format(time.RFC3339), + "metadata": map[string]string{}, + }}, + {"occurred_at too old", map[string]interface{}{ + "user_id": "u1", + "event_type": "click", + "occurred_at": time.Now().Add(-31 * 24 * time.Hour).Format(time.RFC3339), + "metadata": map[string]string{}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + body, _ := json.Marshal(tt.payload) + req := httptest.NewRequest(http.MethodPost, "/events", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusBadRequest), "case: "+tt.name) + }) + } +} + +func TestListUserEvents(t *testing.T) { + RegisterTestingT(t) + srv := newTestServer(t) + + // Ingest two events for user-42. + for i, ts := range []time.Time{ + time.Now().Add(-2 * time.Minute), + time.Now().Add(-1 * time.Minute), + } { + payload := map[string]interface{}{ + "user_id": "user-42", + "event_type": "click", + "occurred_at": ts.UTC().Format(time.RFC3339), + "metadata": map[string]int{"seq": i}, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/events", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusCreated)) + } + + req := httptest.NewRequest(http.MethodGet, "/users/user-42/events", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var events []map[string]interface{} + Expect(json.NewDecoder(rec.Body).Decode(&events)).To(Succeed()) + Expect(events).To(HaveLen(2)) + Expect(events[0]["user_id"]).To(Equal("user-42")) + // First result should be the newest (occurred_at DESC). + t0, _ := time.Parse(time.RFC3339, events[0]["occurred_at"].(string)) + t1, _ := time.Parse(time.RFC3339, events[1]["occurred_at"].(string)) + Expect(t0.After(t1) || t0.Equal(t1)).To(BeTrue()) +} + +func TestListUserEvents_Empty(t *testing.T) { + RegisterTestingT(t) + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/users/nobody/events", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var events []map[string]interface{} + Expect(json.NewDecoder(rec.Body).Decode(&events)).To(Succeed()) + Expect(events).To(HaveLen(0)) +} diff --git a/cmd/activity-watcher/smoke.sh b/cmd/activity-watcher/smoke.sh new file mode 100755 index 00000000..a5ac6848 --- /dev/null +++ b/cmd/activity-watcher/smoke.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# smoke.sh — end-to-end smoke test for the activity-watcher service. +# Usage: BASE_URL=http://localhost:8080 ./cmd/activity-watcher/smoke.sh +set -euo pipefail + +BASE="${BASE_URL:-http://localhost:8080}" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; ((PASS++)) || true; } +fail() { echo " FAIL: $1"; ((FAIL++)) || true; } + +echo "=== Activity Watcher Smoke Test ===" +echo " Target: $BASE" +echo + +# AC5: health +echo "--- AC5: GET /health ---" +HEALTH=$(curl -sf "$BASE/health") +if echo "$HEALTH" | grep -q '"ok"'; then + pass "GET /health → 200 {\"status\":\"ok\"}" +else + fail "GET /health → unexpected: $HEALTH" +fi + +# AC1: valid ingest +echo "--- AC1: POST /events (valid) ---" +ID=$(curl -sf -X POST "$BASE/events" \ + -H 'Content-Type: application/json' \ + -d "{\"user_id\":\"smoke-user\",\"event_type\":\"page_view\",\"occurred_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ -d '1 minute ago' 2>/dev/null || date -u -v-1M +%Y-%m-%dT%H:%M:%SZ)\",\"metadata\":{\"page\":\"/home\"}}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || true) +if [ -n "$ID" ]; then + pass "POST /events → 201, id=$ID" +else + fail "POST /events → did not return an id" +fi + +# AC2: missing fields → 400 +echo "--- AC2: POST /events (invalid, missing fields) ---" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/events" \ + -H 'Content-Type: application/json' -d '{}') +if [ "$STATUS" = "400" ]; then + pass "POST /events {} → 400" +else + fail "POST /events {} → expected 400, got $STATUS" +fi + +# AC2: future occurred_at → 400 +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/events" \ + -H 'Content-Type: application/json' \ + -d '{"user_id":"u","event_type":"e","occurred_at":"2099-01-01T00:00:00Z","metadata":{}}') +if [ "$STATUS" = "400" ]; then + pass "POST /events future date → 400" +else + fail "POST /events future date → expected 400, got $STATUS" +fi + +# AC4: retrieve events for user +echo "--- AC4: GET /users/smoke-user/events ---" +EVENTS=$(curl -sf "$BASE/users/smoke-user/events") +COUNT=$(echo "$EVENTS" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0) +if [ "$COUNT" -ge 1 ]; then + pass "GET /users/smoke-user/events → $COUNT event(s)" +else + fail "GET /users/smoke-user/events → expected ≥1 events, got $COUNT" +fi + +echo +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/docs/design/2026-06-12/activity-watcher-phase1/design.md b/docs/design/2026-06-12/activity-watcher-phase1/design.md new file mode 100644 index 00000000..c7418953 --- /dev/null +++ b/docs/design/2026-06-12/activity-watcher-phase1/design.md @@ -0,0 +1,91 @@ +# Activity Watcher — Phase 1 Backend MVP + +**Date:** 2026-06-12 +**Slice:** `activity-watcher-phase-1-backend-mvp` +**Status:** Implemented + +## Problem Statement + +The platform lacked any mechanism to record user or system activity events. This design covers the minimal backend service to ingest, persist, and retrieve activity events — enough to prove the pattern and unblock downstream consumers. + +## Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Language | Go 1.26 | Matches repo standard; single static binary | +| Storage | SQLite (WAL mode) via `modernc.org/sqlite` | Zero infra, ACID, no CGO required, trivially swappable in Phase 2 | +| Router | `net/http` stdlib (Go 1.22 pattern routing) | 3 routes; no framework needed | +| Architecture | Handler → Service → Repository | Layered; Repository interface allows Phase 2 swap to Postgres | +| Event ID | UUID v4 | Safe for distributed insertion in Phase 2 | +| Timestamps | `occurred_at` (client) + `created_at` (server) | Correct semantic split for activity tracking | +| Auth | None (Phase 1) | Internal only; stub TODO comment in handler | + +## Directory Layout + +``` +cmd/activity-watcher/ + main.go — wiring, signal handling, HTTP server + main_test.go — integration tests (real SQLite, httptest) + helpers_test.go — test utilities + Dockerfile — multi-stage, CGO_ENABLED=0 + smoke.sh — end-to-end smoke script + +internal/activitywatcher/ + model/event.go — Event, EventInput, Validate() + repository/interface.go — EventRepository interface + repository/sqlite.go — SQLite implementation + schema migration + service/events.go — CreateEvent, ListUserEvents + handler/events.go — POST /events, GET /users/:id/events + handler/health.go — GET /health + +api/activity-watcher-openapi.yaml — OpenAPI 3.0 spec +``` + +## API + +| Method | Path | Purpose | +|---|---|---| +| GET | /health | Liveness probe | +| POST | /events | Ingest event | +| GET | /users/{user_id}/events | List user's events, newest first | + +## Data Model + +```sql +CREATE TABLE events ( + id TEXT PRIMARY KEY, -- UUID v4 + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + occurred_at DATETIME NOT NULL, -- client-supplied, RFC3339 + metadata TEXT NOT NULL DEFAULT '{}', -- free-form JSON + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_events_user_occurred ON events (user_id, occurred_at DESC); +PRAGMA journal_mode=WAL; +``` + +## Validation Rules + +| Field | Rule | +|---|---| +| user_id | Required, non-empty, ≤255 chars | +| event_type | Required, non-empty, ≤255 chars | +| occurred_at | Required, RFC3339, not future, not >30 days old | +| metadata | Required, valid JSON object (may be `{}`) | + +## Configuration + +All config via environment variables: + +| Variable | Default | Purpose | +|---|---|---| +| DB_PATH | /data/events.db | SQLite file path | +| PORT | 8080 | HTTP listen port | +| LOG_LEVEL | info | Logging verbosity (debug/info/warn/error) | + +## Phase 2 Roadmap + +- Swap `SQLiteRepository` for `PostgresRepository` (interface already extracted) +- Add API-key middleware (stub `TODO: auth` present in handler) +- Pagination + filtering on list endpoint +- Metrics endpoint (`/metrics`) diff --git a/go.mod b/go.mod index a4d9aedc..20300557 100644 --- a/go.mod +++ b/go.mod @@ -342,6 +342,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/natefinch/atomic v1.0.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect @@ -377,6 +378,7 @@ require ( github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/raeperd/recvcheck v0.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/zerolog v1.34.0 // indirect @@ -490,6 +492,10 @@ require ( k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect lukechampine.com/frand v1.5.1 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index be3a2f8f..eb43f43a 100644 --- a/go.sum +++ b/go.sum @@ -481,6 +481,7 @@ github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9 github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -713,6 +714,8 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= @@ -829,6 +832,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -1281,6 +1286,14 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= lukechampine.com/frand v1.5.1 h1:fg0eRtdmGFIxhP5zQJzM1lFDbD6CUfu/f+7WgAZd5/w= lukechampine.com/frand v1.5.1/go.mod h1:4VstaWc2plN4Mjr10chUD46RAVGWhpkZ5Nja8+Azp0Q= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= mvdan.cc/gofumpt v0.10.0 h1:yGGpRS2pBN2OQIi7b21IXknJna7faPkFaVfHLrN6Euo= mvdan.cc/gofumpt v0.10.0/go.mod h1:sU2ElXHzOEmvoPqfutYG7uunlueR4K2T1JFml40SzP4= mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= diff --git a/internal/activitywatcher/handler/events.go b/internal/activitywatcher/handler/events.go new file mode 100644 index 00000000..44ba844a --- /dev/null +++ b/internal/activitywatcher/handler/events.go @@ -0,0 +1,64 @@ +package handler + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/simple-container-com/api/internal/activitywatcher/model" + "github.com/simple-container-com/api/internal/activitywatcher/service" +) + +// EventHandler handles HTTP requests for activity events. +type EventHandler struct { + svc *service.EventService + logger *slog.Logger +} + +// NewEventHandler constructs an EventHandler. +func NewEventHandler(svc *service.EventService, logger *slog.Logger) *EventHandler { + return &EventHandler{svc: svc, logger: logger} +} + +// Create handles POST /events. +// TODO: auth — add API-key middleware in Phase 2. +func (h *EventHandler) Create(w http.ResponseWriter, r *http.Request) { + var input model.EventInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + event, err := h.svc.CreateEvent(r.Context(), &input) + if err != nil { + // Validation errors begin with "validation:" + h.logger.ErrorContext(r.Context(), "create event", "error", err) + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"id": event.ID}) +} + +// ListByUser handles GET /users/{user_id}/events. +func (h *EventHandler) ListByUser(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("user_id") + events, err := h.svc.ListUserEvents(r.Context(), userID) + if err != nil { + h.logger.ErrorContext(r.Context(), "list events", "user_id", userID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to list events") + return + } + if events == nil { + events = []*model.Event{} + } + writeJSON(w, http.StatusOK, events) +} + +func writeJSON(w http.ResponseWriter, status int, body interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/activitywatcher/handler/health.go b/internal/activitywatcher/handler/health.go new file mode 100644 index 00000000..9590d60d --- /dev/null +++ b/internal/activitywatcher/handler/health.go @@ -0,0 +1,19 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +// HealthHandler handles GET /health liveness probes. +type HealthHandler struct{} + +// NewHealthHandler constructs a HealthHandler. +func NewHealthHandler() *HealthHandler { return &HealthHandler{} } + +// Health responds with 200 {"status":"ok"}. +func (h *HealthHandler) Health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/internal/activitywatcher/model/event.go b/internal/activitywatcher/model/event.go new file mode 100644 index 00000000..435db915 --- /dev/null +++ b/internal/activitywatcher/model/event.go @@ -0,0 +1,64 @@ +package model + +import ( + "encoding/json" + "fmt" + "time" +) + +// Event is the core domain object persisted by the activity watcher. +type Event struct { + ID string `json:"id"` + UserID string `json:"user_id"` + EventType string `json:"event_type"` + OccurredAt time.Time `json:"occurred_at"` + Metadata json.RawMessage `json:"metadata"` + CreatedAt time.Time `json:"created_at"` +} + +// EventInput is the inbound payload for POST /events. +type EventInput struct { + UserID string `json:"user_id"` + EventType string `json:"event_type"` + OccurredAt time.Time `json:"occurred_at"` + Metadata json.RawMessage `json:"metadata"` +} + +const ( + maxFieldLen = 255 + maxOccurredAtAge = 30 * 24 * time.Hour +) + +// Validate returns an error if any field fails the agreed validation rules. +func (e *EventInput) Validate() error { + if e.UserID == "" { + return fmt.Errorf("user_id is required") + } + if len(e.UserID) > maxFieldLen { + return fmt.Errorf("user_id must be at most %d characters", maxFieldLen) + } + if e.EventType == "" { + return fmt.Errorf("event_type is required") + } + if len(e.EventType) > maxFieldLen { + return fmt.Errorf("event_type must be at most %d characters", maxFieldLen) + } + if e.OccurredAt.IsZero() { + return fmt.Errorf("occurred_at is required") + } + now := time.Now().UTC() + if e.OccurredAt.After(now) { + return fmt.Errorf("occurred_at must not be in the future") + } + if now.Sub(e.OccurredAt) > maxOccurredAtAge { + return fmt.Errorf("occurred_at must not be more than 30 days in the past") + } + if len(e.Metadata) == 0 { + return fmt.Errorf("metadata is required") + } + var obj map[string]interface{} + if err := json.Unmarshal(e.Metadata, &obj); err != nil { + return fmt.Errorf("metadata must be a valid JSON object") + } + return nil +} diff --git a/internal/activitywatcher/repository/interface.go b/internal/activitywatcher/repository/interface.go new file mode 100644 index 00000000..874f5a89 --- /dev/null +++ b/internal/activitywatcher/repository/interface.go @@ -0,0 +1,16 @@ +package repository + +import ( + "context" + + "github.com/simple-container-com/api/internal/activitywatcher/model" +) + +// EventRepository is the storage interface for activity events. +// Implementations can swap the backend (SQLite → Postgres) without touching handlers. +type EventRepository interface { + // Create persists a new event and returns it with server-assigned fields populated. + Create(ctx context.Context, event *model.Event) error + // ListByUser returns events for the given user, newest first. + ListByUser(ctx context.Context, userID string) ([]*model.Event, error) +} diff --git a/internal/activitywatcher/repository/sqlite.go b/internal/activitywatcher/repository/sqlite.go new file mode 100644 index 00000000..89a3fd0d --- /dev/null +++ b/internal/activitywatcher/repository/sqlite.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + _ "modernc.org/sqlite" // pure-Go SQLite driver; no CGO required + + "github.com/simple-container-com/api/internal/activitywatcher/model" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + occurred_at DATETIME NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_events_user_occurred + ON events (user_id, occurred_at DESC); +PRAGMA journal_mode=WAL; +` + +// SQLiteRepository is an EventRepository backed by SQLite. +type SQLiteRepository struct { + db *sql.DB +} + +// NewSQLiteRepository opens (or creates) a SQLite database at dbPath and +// applies the schema migration. +func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + // Single writer; WAL allows concurrent readers. + db.SetMaxOpenConns(1) + if _, err = db.Exec(schema); err != nil { + return nil, fmt.Errorf("apply schema: %w", err) + } + return &SQLiteRepository{db: db}, nil +} + +// Close releases the underlying database connection. +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +// Create persists e and fills e.CreatedAt from the DB default. +func (r *SQLiteRepository) Create(ctx context.Context, e *model.Event) error { + meta, err := json.Marshal(e.Metadata) + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + _, err = r.db.ExecContext(ctx, + `INSERT INTO events (id, user_id, event_type, occurred_at, metadata) + VALUES (?, ?, ?, ?, ?)`, + e.ID, + e.UserID, + e.EventType, + e.OccurredAt.UTC().Format(time.RFC3339Nano), + string(meta), + ) + if err != nil { + return fmt.Errorf("insert event: %w", err) + } + row := r.db.QueryRowContext(ctx, `SELECT created_at FROM events WHERE id = ?`, e.ID) + var createdAt string + if err = row.Scan(&createdAt); err != nil { + return fmt.Errorf("fetch created_at: %w", err) + } + e.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) + if err != nil { + // Fallback: SQLite default format may differ; try without nano. + e.CreatedAt, err = time.Parse("2006-01-02T15:04:05Z", createdAt) + if err != nil { + e.CreatedAt = time.Now().UTC() + } + } + return nil +} + +// ListByUser returns all events for userID, newest first. +func (r *SQLiteRepository) ListByUser(ctx context.Context, userID string) ([]*model.Event, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, user_id, event_type, occurred_at, metadata, created_at + FROM events WHERE user_id = ? ORDER BY occurred_at DESC`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("query events: %w", err) + } + defer rows.Close() + + var events []*model.Event + for rows.Next() { + var ( + e model.Event + occurredAtStr, createdAt string + metaStr string + ) + if err = rows.Scan(&e.ID, &e.UserID, &e.EventType, &occurredAtStr, &metaStr, &createdAt); err != nil { + return nil, fmt.Errorf("scan event: %w", err) + } + e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurredAtStr) + if e.OccurredAt.IsZero() { + e.OccurredAt, _ = time.Parse("2006-01-02T15:04:05Z", occurredAtStr) + } + e.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + if e.CreatedAt.IsZero() { + e.CreatedAt, _ = time.Parse("2006-01-02T15:04:05Z", createdAt) + } + e.Metadata = json.RawMessage(metaStr) + events = append(events, &e) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("iterate events: %w", err) + } + return events, nil +} diff --git a/internal/activitywatcher/service/events.go b/internal/activitywatcher/service/events.go new file mode 100644 index 00000000..af4e866d --- /dev/null +++ b/internal/activitywatcher/service/events.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/simple-container-com/api/internal/activitywatcher/model" + "github.com/simple-container-com/api/internal/activitywatcher/repository" +) + +// EventService encapsulates business logic for activity events. +type EventService struct { + repo repository.EventRepository +} + +// NewEventService constructs a service with the given repository. +func NewEventService(repo repository.EventRepository) *EventService { + return &EventService{repo: repo} +} + +// CreateEvent validates input, assigns a UUID, and persists the event. +func (s *EventService) CreateEvent(ctx context.Context, input *model.EventInput) (*model.Event, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("validation: %w", err) + } + event := &model.Event{ + ID: uuid.NewString(), + UserID: input.UserID, + EventType: input.EventType, + OccurredAt: input.OccurredAt, + Metadata: input.Metadata, + } + if err := s.repo.Create(ctx, event); err != nil { + return nil, fmt.Errorf("persist: %w", err) + } + return event, nil +} + +// ListUserEvents returns all events for a user, newest first. +func (s *EventService) ListUserEvents(ctx context.Context, userID string) ([]*model.Event, error) { + if userID == "" { + return nil, fmt.Errorf("user_id is required") + } + return s.repo.ListByUser(ctx, userID) +}