feat(project): SSE push updates for project board pages #7

Merged
oleks merged 7 commits from project-board-sse into main 2026-05-15 22:15:26 +03:00
Owner

Adds Server-Sent Events push so project board pages auto-update when ANY board mutation happens (card move/add/remove + column CRUD + project edit/delete), without page refresh.

Design

  • Transport: reuses Gitea's existing /user/events SSE endpoint and the UserEventsSharedWorker SharedWorker pattern. Topic isolation via event names like project-board.{project_id} — no new endpoints, no new auth.
  • Publish sites: service-layer wrappers in services/projects/{column,issue,project}.go that call the underlying model funcs and then emit events. All routers (web + REST for repo/user/org scopes) were migrated to call the wrappers, so every mutation publishes exactly once regardless of entry point.
  • Permission filter at publish time: connectedUIDsWithProjectAccess iterates eventsource.Manager.ConnectedUIDs() and checks per-user project read access before sending. No card content leaks to users without access.
  • Self-tab suppression: each tab generates a UUID session_tag on load, attaches it as X-Session-Tag header on every mutation request. Server stitches it into the published event payload. Frontend skips events where the tag matches its own — no duplicate animations from the originating tab's drag-drop.

Event taxonomy

card.moved, card.linked, card.unlinked, column.created, column.updated, column.deleted, column.reordered, project.updated, project.deleted. Payloads carry only IDs + minimal fields needed for DOM patching; card.linked does a targeted column re-fetch rather than embedding rendered HTML.

Notable decisions

  1. Service wrappers, not model-layer publishes — model-layer publish would have created a models → services/project_events import cycle (project_events imports models/project for the access check). Wrappers avoid the cycle.
  2. ColumnDeleted emits per-issue CardMoved follow-ups snapshotted before the delete, so the frontend can patch declaratively without an extra fetch.
  3. Session-tag via context.Value rather than added params — propagates from middleware (routers/common.SessionTagMiddleware) without rippling through many signatures.

Tests

  • services/project_events/events_test.go — 15 unit tests covering event-name format, JSON payload shape per type, session-tag context propagation, explicit-tag override, access-filter inclusion/exclusion, no-connections short-circuit, broadcast fan-out target list. All pass.
  • golangci-lint clean on changed packages.
  • tsc --noEmit, vite build clean.

Limitations

  • In-process event bus — same constraint as Gitea's existing notification SSE. Multi-replica deployments would need pub/sub. This fork is single-process; documented as a known limitation.
  • column.created falls back to window.location.reload() as the first-pass DOM handler (rare event). Optimize later if needed.

Manual smoke

Open project board in two browser tabs. Drag a card in tab A → tab B updates without refresh. Same for column edit, card link via API, etc.

Adds Server-Sent Events push so project board pages auto-update when ANY board mutation happens (card move/add/remove + column CRUD + project edit/delete), without page refresh. ## Design - **Transport:** reuses Gitea's existing `/user/events` SSE endpoint and the `UserEventsSharedWorker` SharedWorker pattern. Topic isolation via event names like `project-board.{project_id}` — no new endpoints, no new auth. - **Publish sites:** service-layer wrappers in `services/projects/{column,issue,project}.go` that call the underlying model funcs and then emit events. All routers (web + REST for repo/user/org scopes) were migrated to call the wrappers, so every mutation publishes exactly once regardless of entry point. - **Permission filter at publish time:** `connectedUIDsWithProjectAccess` iterates `eventsource.Manager.ConnectedUIDs()` and checks per-user project read access before sending. No card content leaks to users without access. - **Self-tab suppression:** each tab generates a UUID `session_tag` on load, attaches it as `X-Session-Tag` header on every mutation request. Server stitches it into the published event payload. Frontend skips events where the tag matches its own — no duplicate animations from the originating tab's drag-drop. ## Event taxonomy `card.moved`, `card.linked`, `card.unlinked`, `column.created`, `column.updated`, `column.deleted`, `column.reordered`, `project.updated`, `project.deleted`. Payloads carry only IDs + minimal fields needed for DOM patching; `card.linked` does a targeted column re-fetch rather than embedding rendered HTML. ## Notable decisions 1. **Service wrappers, not model-layer publishes** — model-layer publish would have created a `models → services/project_events` import cycle (project_events imports `models/project` for the access check). Wrappers avoid the cycle. 2. **`ColumnDeleted` emits per-issue `CardMoved` follow-ups** snapshotted before the delete, so the frontend can patch declaratively without an extra fetch. 3. **Session-tag via `context.Value`** rather than added params — propagates from middleware (`routers/common.SessionTagMiddleware`) without rippling through many signatures. ## Tests - `services/project_events/events_test.go` — 15 unit tests covering event-name format, JSON payload shape per type, session-tag context propagation, explicit-tag override, access-filter inclusion/exclusion, no-connections short-circuit, broadcast fan-out target list. All pass. - `golangci-lint` clean on changed packages. - `tsc --noEmit`, `vite build` clean. ## Limitations - **In-process event bus** — same constraint as Gitea's existing notification SSE. Multi-replica deployments would need pub/sub. This fork is single-process; documented as a known limitation. - **`column.created` falls back to `window.location.reload()`** as the first-pass DOM handler (rare event). Optimize later if needed. ## Manual smoke Open project board in two browser tabs. Drag a card in tab A → tab B updates without refresh. Same for column edit, card link via API, etc.
oleks added 7 commits 2026-05-15 22:15:22 +03:00
Expose a snapshot of currently registered uids so fan-out broadcasters
can pre-filter recipients before calling SendMessage.
New services/project_events package marshals typed payloads to JSON,
wraps them in SSE events named project-board.{project_id}, and fans
them out via the eventsource manager to every connected user that
has read access to the project. Each Publish* helper runs the
broadcast in a goroutine so request handlers stay responsive.

Includes WithSessionTag / SessionTagFromContext for propagating an
X-Session-Tag value down to the publisher (so the originating browser
tab can suppress its own echo).

Unit tests cover event-name format, payload JSON shape, session-tag
propagation, the connected-uids access filter, and the broadcast
fan-out path.
Wrap the model-layer column/project/issue mutation funcs in service-layer
helpers (CreateColumn, EditColumn, DeleteColumn, ReorderColumns,
DeleteProject, AssignOrRemoveProjects) that publish the matching SSE
event after the underlying call succeeds.

Routers (web + REST) are migrated to call these service helpers so the
publish side-effects fire uniformly across repo, user, and org scopes.

DeleteColumn snapshots the column's issues before deletion and emits
one CardMoved per affected issue (alongside the ColumnDeleted event)
so the receiving tab can patch the DOM without a full reload.

Move-issue publishing fires after the txn commits so we never emit
events for moves that get rolled back.
The project board view now opens a SharedWorker EventSource
subscription scoped to project-board.{id} and patches the DOM in
response to incoming events:

- card.moved: relocates the card to the destination column and
  refreshes both column count badges; falls back to a column refetch
  when the card isn't currently rendered (filtered out / new).
- card.linked: refetches the destination column's issue list and
  updates the count badge.
- card.unlinked: removes the card and updates the badge.
- column.created: page reload (rare event, simplest path).
- column.updated: in-place title + color/contrast updates.
- column.deleted: removes the column element.
- column.reordered: re-attaches columns in the new sort order.
- project.updated: updates the header title + description text.
- project.deleted: navigates up to the projects listing.

The board template now exposes data-project-id, data-project-scope
(repo/user/org), data-project-owner, and data-project-repo so the
subscriber can build the right column-issues refetch URL.

Each mutation request the page makes also carries a
crypto.randomUUID-generated X-Session-Tag header; the receiving
handler compares it against the incoming payload's session_tag to
suppress own-tab echoes (the optimistic local update is already
authoritative).
Adds a router middleware that extracts the X-Session-Tag header from
each request and decorates the request context via
project_events.WithSessionTag. Service- and model-layer publishers
then read it back via project_events.SessionTagFromContext and
attach it to outgoing CardMoved / CardLinked / CardUnlinked events.

The originating browser tab compares the incoming session_tag to
its own and skips the echo, avoiding double-application of the
optimistic local update. Other tabs see no tag match and apply the
event normally.

Wired into both the web router chain (before Contexter so the base
context inherits the tag) and the API router chain (before
APIContexter for the same reason).
oleks merged commit 9c1699feb5 into main 2026-05-15 22:15:26 +03:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: oleks/gitea#7