Skip to content

Real-Time Collaboration

TruePPM uses Django Channels 4 to push project changes to connected clients over WebSocket.

Mint a single-use ticket (POST /api/v1/ws/ticket/, 30-second TTL), then connect:

ws://localhost:8000/ws/v1/projects/{project_id}/?ticket=<ticket>

Authentication uses a short-lived, single-use ticket so no JWT ever appears in a WebSocket URL or access log (RFC 6750 §2.3) — see the WebSocket API reference for the handshake. The deprecated ?token=<jwt> parameter is disabled by default (it leaked the JWT into access logs) and is opt-in via TRUEPPM_WS_LEGACY_TOKEN_AUTH_ENABLED for one last release. Requires at least the Member role on the project. Viewers are rejected with close code 4003. If a connected user’s membership is revoked or demoted below Member mid-session, the server evicts the live socket immediately with close code 4003 — revocation does not wait for the client to disconnect.

Every event arrives as a JSON envelope with an event_type name and a minimal payload:

{
"event_type": "<event_name>",
"payload": { ... }
}

Payloads are intentionally small (usually just {"id": "<uuid>"}) — fetch the resource for full state. Treat delivery as best-effort: refetch affected resources after a reconnect rather than relying on having seen every event.

The complete, authoritative event catalog is frozen in the API test suite (packages/api/tests/apps/sync/test_broadcast.py, FROZEN_WS_EVENT_TYPES) and documented in the WebSocket API reference. The events you will see most often:

A schedule recompute broadcasts two events when the CPM run commits:

EventPayload
cpm_complete{"project_finish": "...", "critical_path": [...]}
task_dates_updated{"count": N, "tasks": [{"id", "early_start", "early_finish", "late_start", "late_finish", "total_float", "free_float", "is_critical", "planned_start", "duration"}, ...]} — per-task CPM date deltas so Gantt bars slide without a full re-fetch. count and tasks cover only the tasks whose CPM fields actually changed in that recalculation, not every task in the project — a single-field edit typically ships a handful of rows regardless of project size. When the changed set is genuinely too large to ship economically (a first-ever pass, a calendar change, a project-start shift), the payload is {"count": N, "truncated": true} and clients should re-fetch.
EventPayload
task_created{"id": "..."}
task_updated{"id": "..."}
task_deleted{"id": "..."}
EventPayload
dependency_created{"id": "..."}
dependency_updated{"id": "..."}
dependency_deleted{"id": "..."}
dependency_accepted{"id": "..."}
dependency_rejected{"id": "..."}
EventPayload
risk_created / risk_updated / risk_deleted{"id": "..."}

Sprint lifecycle events (sprint_created, sprint_updated, sprint_deleted, sprint_activated, sprint_closed, sprint_cancelled, sprint_scope_changed) and baseline events (baseline_created, baseline_activated, baseline_deleted) follow the same minimal-payload convention.

EventPayload
member_added{"membership_id": "...", "user_id": "...", "role": 100}
member_role_changed{"membership_id": "...", "user_id": "...", "role": 200}
member_removed{"membership_id": "...", "user_id": "..."}
EventPayload
presence_join{"user_id": "...", "display_name": "..."}
presence_leave{"user_id": "...", "display_name": "..."}

Presence is held in Valkey under a 60-second TTL that each open socket re-arms on a server-side timer. The roster tracks who is connected, not who is active — sitting on a quiet project without touching anything keeps you listed. The TTL exists to reap a socket that died without a clean disconnect (a killed worker, a network partition), so a name disappears within a minute of the connection genuinely going away. Clients send no heartbeat and need none.

Beyond these, the catalog also covers comments, attachments, assignments and roster changes, board configuration and saved views, programs, project lifecycle, and workshops — see the WebSocket API reference for the full taxonomy and the WebSocket ↔ webhook event mapping.

All broadcasts are deferred inside transaction.on_commit() — events only fire if the database write committed successfully. No phantom events for rolled-back transactions.

Uses Valkey (the BSD-licensed Linux Foundation fork of Redis; wire-compatible) configured via REDIS_URL. All api and celery containers share the same Valkey instance, so Celery-originated broadcasts (e.g. cpm_complete) reach WebSocket clients connected to any API container — safe for horizontal scaling. Existing Redis-compatible managed services (ElastiCache, Memorystore, Azure Cache for Redis) work as drop-in alternatives.

// Mint a single-use ticket, then connect — keeps the JWT out of the URL.
const { ticket } = await fetch('/api/v1/ws/ticket/', {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
}).then((r) => r.json());
const ws = new WebSocket(
`ws://localhost:8000/ws/v1/projects/${projectId}/?ticket=${ticket}`
);
ws.onmessage = (event) => {
const { event_type, payload } = JSON.parse(event.data);
if (event_type === 'cpm_complete') fetchSchedule(projectId);
if (event_type === 'task_updated') fetchTask(payload.id);
};