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
- `GET /api/activity` returns the signed-in member's workspace's activity events, newest first. Events are only ever inserted (never updated or deleted) and their `id` comes from a `bigserial` in insert order, so newest first means `id` descending. Each event carries `id`, `actor`, `kind`, `summary` and `createdAt` (RFC 3339).
- An optional `limit` query parameter sets the page size: an integer from 1 to 100, default 25. A `limit` that is present but empty, not an integer or out of range is 400 `{"error": "invalid_limit"}`. An optional `cursor` is the previous page's `nextCursor`; a `cursor` that is present but empty, not an integer or not positive is 400 `{"error": "invalid_cursor"}`.
- The response is `{"events": [...], "nextCursor": ...}`. A page holds up to `limit` events. `nextCursor` is a string to pass back as `cursor`, and `null` when no older event exists beyond the page, so a client never has to fetch an empty page to learn it reached the end. A workspace with no events gets 200 `{"events": [], "nextCursor": null}`.
- Following `nextCursor` from the first page until it is `null` returns every event the workspace had when the first page was read, each exactly once, in order. Events added meanwhile are outside this guarantee; the client picks them up when it reads the feed again from the top.
- A request makes one database query: the handler reads `limit + 1` rows and uses the extra row only to tell whether another page follows.
- Database errors answer 500 `{"error": "internal"}` and are logged; no internals reach the client.
Files touched
- cmd/api/routes.go
- internal/activity/store.go
- internal/activity/handlers.go
--- 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`