Workspace activity feed with cursor pagination

Go · Go · intermediate · modification

Adds `GET /api/activity` for the new activity sidebar: the workspace's events newest first, `limit` per page (default 25, max 100) and a `cursor` taken from the previous page's `nextCursor`. Paging is keyset on the primary key over the existing `(workspace_id, id)` index, so a deep page costs about the same as the first one. The handler reads one row past the page to tell whether another page follows, so the sidebar stops on a `null` cursor instead of fetching an empty page to find the end. Bad `limit` or `cursor` values are 400s; database errors are logged and answered with a generic 500.

Go 1.22 project tracker API on the standard library (`net/http` method-pattern `ServeMux`, `log/slog`, `database/sql` with the pgx v5 stdlib driver). `main` wraps the mux in the auth middleware, so every handler runs for a signed-in workspace member; `auth.FromContext(ctx)` returns the signed-in `auth.Member` (with `UserID` and `WorkspaceID`) and an `ok` flag. `httpx.WriteJSON(w, status, v)` (existing) sets the JSON content type, writes the status and encodes `v`. Table (existing, written by the projects and tasks code): `activity_events(id bigserial primary key, workspace_id bigint not null, actor_name text not null, kind text not null, summary text not null, created_at timestamptz not null)` with an index on `(workspace_id, id)`; ids stay far below 2^53, so the JSON numbers are exact in the browser. The sidebar loads the first page and follows `nextCursor` as the user scrolls.

Requirements

Files touched

--- cmd/api/routes.go
 	"net/http"
 
+	"example.com/tracker/internal/activity"
 	"example.com/tracker/internal/projects"
 	"example.com/tracker/internal/tasks"
 // middleware, so handlers only run for signed-in workspace members.
 func registerRoutes(mux *http.ServeMux, db *sql.DB) {
+	activity.Register(mux, db)
 	projects.Register(mux, db)
 	tasks.Register(mux, db)
--- internal/activity/store.go
+package activity
+
+import (
+	"context"
+	"database/sql"
+)
+
+const (
+	newestSQL = `SELECT id, actor_name, kind, summary, created_at
+		FROM activity_events
+		WHERE workspace_id = $1
+		ORDER BY id DESC
+		LIMIT $2`
+	olderSQL = `SELECT id, actor_name, kind, summary, created_at
+		FROM activity_events
+		WHERE workspace_id = $1 AND id < $2
+		ORDER BY id DESC
+		LIMIT $3`

Review this PR

Go practice