Real-Time Collaboration
TruePPM uses Django Channels 4 to push project changes to connected clients over WebSocket.
Connecting
Section titled “Connecting”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.
Event format
Section titled “Event format”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.
Events
Section titled “Events”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:
Schedule
Section titled “Schedule”A schedule recompute broadcasts two events when the CPM run commits:
| Event | Payload |
|---|---|
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. |
| Event | Payload |
|---|---|
task_created | {"id": "..."} |
task_updated | {"id": "..."} |
task_deleted | {"id": "..."} |
Dependencies
Section titled “Dependencies”| Event | Payload |
|---|---|
dependency_created | {"id": "..."} |
dependency_updated | {"id": "..."} |
dependency_deleted | {"id": "..."} |
dependency_accepted | {"id": "..."} |
dependency_rejected | {"id": "..."} |
| Event | Payload |
|---|---|
risk_created / risk_updated / risk_deleted | {"id": "..."} |
Sprints and baselines
Section titled “Sprints and baselines”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.
Members
Section titled “Members”| Event | Payload |
|---|---|
member_added | {"membership_id": "...", "user_id": "...", "role": 100} |
member_role_changed | {"membership_id": "...", "user_id": "...", "role": 200} |
member_removed | {"membership_id": "...", "user_id": "..."} |
Presence
Section titled “Presence”| Event | Payload |
|---|---|
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.
Broadcast safety
Section titled “Broadcast safety”All broadcasts are deferred inside transaction.on_commit() — events only fire if the database write committed successfully. No phantom events for rolled-back transactions.
Channel layer
Section titled “Channel layer”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.
JavaScript example
Section titled “JavaScript example”// 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);};