Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
134 changes: 134 additions & 0 deletions api/activity-watcher-openapi.yaml
Original file line number Diff line number Diff line change
@@ -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"
17 changes: 17 additions & 0 deletions cmd/activity-watcher/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
10 changes: 10 additions & 0 deletions cmd/activity-watcher/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package main

import (
"io"
"log/slog"
)

func newTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
89 changes: 89 additions & 0 deletions cmd/activity-watcher/main.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading