This document is the consolidated reference for the Phase 7 — Team Collaboration endpoints. It covers invitations, membership, activity/audit, notifications, comments, settings, and presence.
Every endpoint is served by the collaboration blueprint
(app/collaboration/routes.py) unless noted, and routes authorization through
the central capability module app/services/permissions.py (issue #142). The
permission matrix is defined in CAPABILITIES there and rendered in the
developer guide; the tracking issue is #136.
- Auth: every JSON API route requires a logged-in user (
@login_required). Unauthenticated requests get a 302 to/auth/login. - Roles:
owner>contributor>viewer. The workspaceowneris authoritative viaWorkspace.user_id; everyone else resolves through an active membership row. Non-members resolve to no role and fail closed. - Not found vs forbidden: workspace routes return
404for non-members and non-existent workspaces alike, so callers cannot probe ids (no existence oracle). Capability violations by a known member return403. - Pagination: list endpoints accept
page(1-based) andper_page(1–100, default 20). The activity feed instead uses cursor pagination viabefore=<cursor>. - Errors: JSON bodies like
{"error": "<message>"}with status 400/403/404/409/429. - CSRF: HTML pages are protected by Flask-WTF; JSON API calls use
Content-Type: application/json.
| Capability | Allowed roles |
|---|---|
view_members |
owner, contributor, viewer |
leave_workspace |
contributor, viewer (owner transfers instead) |
manage_members |
owner |
manage_invitations |
owner |
manage_settings |
owner |
transfer_ownership |
owner |
view_audit |
owner |
comment |
owner, contributor, viewer |
view_activity |
owner, contributor, viewer |
heartbeat |
owner, contributor, viewer |
Roles are hierarchical for UI purposes but each capability lists its exact roles; an unknown/typo'd role never grants an owner capability.
POST /collaboration/api/workspaces/<workspace_id>/invitations — owner
(manage_invitations)
Request:
{"email": "newguy@example.com", "role": "viewer"}emailrequired, validated, lowercased; max 255 chars.roleoptional (owneris rejected by validation;contributor/viewerallowed); defaults to the workspace settingdefault_member_role.- Fails
403when the workspace hasinvitations_enabled = false. - Fails
400for self-invite / invalid role / invalid email. - Fails
409when the email already belongs to an active member or has a pending invitation.
Response 201 — the invitation plus the one-time raw token (the token is
stored hashed as SHA-256 and delivered only via the invite email link):
{
"id": 12,
"workspace_id": 3,
"invited_by": 1,
"inviter_username": "alice",
"email": "newguy@example.com",
"role": "viewer",
"status": "pending",
"expires_at": "2026-08-22T10:00:00+00:00",
"accepted_by": null,
"accepted_at": null,
"created_at": "2026-08-15T10:00:00+00:00",
"token": "3f9c...e2a1"
}GET /collaboration/api/workspaces/<workspace_id>/invitations — owner
(manage_invitations)
Query params: status (optional filter: pending | accepted | declined |
cancelled | expired), page, per_page.
Response 200:
{
"items": [ { "id": 12, "workspace_id": 3, "invited_by": 1,
"inviter_username": "alice", "email": "newguy@example.com",
"role": "viewer", "status": "pending",
"expires_at": "2026-08-22T10:00:00+00:00",
"accepted_by": null, "accepted_at": null,
"created_at": "2026-08-15T10:00:00+00:00" } ],
"total": 1,
"page": 1,
"per_page": 20
}No token is ever present in list responses.
DELETE /collaboration/api/workspaces/<workspace_id>/invitations/<invite_id> —
owner (manage_invitations)
404if the invitation is not in this workspace.409if the invitation is notpending.
Response 200 with the updated invitation (status: "cancelled").
GET /collaboration/invitations/<token> — public (no login required)
Renders an HTML landing page describing the invite state (pending, expired,
accepted, declined, cancelled, or invalid). IP rate-limited.
POST /collaboration/api/invitations/<token>/accept — logged-in
429under IP rate limit.404for unknown/expired/cancelled tokens (uniform — no existence oracle).409for previously declined invitations, or when already accepted by a different account.403when the logged-in email does not match the invitation's email.- Accepting reactivates a previously-removed membership (single row, unique
(workspace_id, user_id)constraint preserved) and is atomic.
Response 201 with the membership:
{
"id": 7,
"workspace_id": 3,
"user_id": 9,
"role": "viewer",
"status": "active",
"username": "newguy",
"joined_at": "2026-08-15T10:05:00+00:00",
"removed_at": null,
"last_active_at": null,
"created_at": "2026-08-15T10:05:00+00:00"
}When the user is already an active member it returns 200 with
"idempotent": true.
POST /collaboration/api/invitations/<token>/decline — logged-in
429under IP rate limit;404when the invitation is not pending/valid.
Response 200: {"ok": true}.
GET /workspaces/api/workspaces/<workspace_id>/members — any member
(view_members)
Response 200 — array of memberships (see Accept response shape). Only
status: "active" members are returned, ordered by join time.
POST /workspaces/api/workspaces/<workspace_id>/members — owner
Request: {"username": "bob", "role": "viewer"}.
400missing username / invalid role / owner already a member.404no such username;409already an active member.- Reactivating a previously-removed member preserves the row.
Response 201 with the membership.
PATCH /workspaces/api/workspaces/<workspace_id>/members/<user_id> — owner
Request: {"role": "contributor"}. Response 200 with the membership. A
role change records an activity event and notifies the member.
DELETE /workspaces/api/workspaces/<workspace_id>/members/<user_id> — owner
Soft-delete: status → removed, pending invitations for that user are
cancelled, the member is notified, and history is preserved.
409 if already removed. Response 200: {"ok": true}.
DELETE /collaboration/api/workspaces/<workspace_id>/membership — any
member (leave_workspace)
400for the owner — the owner must transfer ownership first.
Response 200: {"ok": true}.
POST /collaboration/api/workspaces/<workspace_id>/transfer — owner
(transfer_ownership)
Request: {"user_id": 5} (must be an active member).
Atomic: the target becomes owner (Workspace.user_id + membership role), the
previous owner becomes a contributor member, and every project's
denormalized user_id moves to the new owner. Both parties get a
role_change notification.
Response 200: {"ok": true, "workspace": { ...workspace.to_dict() }}.
GET /collaboration/api/workspaces/<workspace_id>/activity — any member
(view_activity)
Query params: event_type, actor (username), before (opaque cursor
returned as next_cursor), per_page (1–100, default 20).
Members never see the audit-sensitive subset; the owner sees everything. The
member feed never includes event metadata.
Response 200:
{
"items": [
{ "id": 5, "workspace_id": 3, "actor_id": 1, "actor_username": "alice",
"event_type": "comment.added", "label": "commented on a project",
"target_type": "project_comment", "target_id": 8,
"created_at": "2026-08-15T09:30:00+00:00" }
],
"next_cursor": "2026-08-15T09:30:00+00:00|5"
}next_cursor is null on the last page. An invalid cursor returns 400.
GET /collaboration/api/workspaces/<workspace_id>/audit — owner
(view_audit)
Query params: event_type, actor, page, per_page. Returns exactly the
audit event subset (AUDIT_EVENT_TYPES) and includes the safe metadata.
Response 200:
{
"items": [
{ "id": 4, "workspace_id": 3, "actor_id": 1, "actor_username": "alice",
"event_type": "invitation.created", "label": "invited someone",
"target_type": "invitation", "target_id": 12,
"created_at": "2026-08-15T10:00:00+00:00",
"metadata": {"email": "newguy@example.com", "role": "viewer"} }
],
"total": 1,
"page": 1,
"per_page": 20
}Audit rows are append-only — there is no update/delete route.
All notification endpoints are strictly current-user scoped: accessing
another user's notification returns 404.
GET /collaboration/api/notifications — logged-in
Query params: unread=1 (filter), page, per_page.
Response 200:
{
"items": [
{ "id": 21, "type": "invitation", "actor_id": 1,
"actor_username": "alice", "workspace_id": 3, "project_id": null,
"payload": {"title": "You've been invited to join Team X",
"workspace": "Team X", "role": "viewer"},
"link": "/collaboration/invitations/3f9c...e2a1",
"is_read": false,
"created_at": "2026-08-15T10:00:00+00:00" }
],
"total": 1,
"unread_count": 1,
"page": 1,
"per_page": 20
}GET /collaboration/api/notifications/count — logged-in
Response 200: {"unread": 1} (drives the header badge; polled by the UI).
POST /collaboration/api/notifications/<notification_id>/read — logged-in
Idempotent. 404 when the notification belongs to another user or does not
exist. Response 200 with the notification.
POST /collaboration/api/notifications/read-all — logged-in
Response 200: {"ok": true}.
GET /collaboration/api/notifications/preferences — logged-in
Response 200 (defaults materialized on first access):
{"invitations": true, "mentions": true, "membership": true, "ai_events": true}PUT /collaboration/api/notifications/preferences — logged-in
Request (any subset): {"ai_events": false}. Response 200 with the full set.
Preferences gate delivery per notification type; unknown types are ignored.
Project discussion is available to the owner and active members of the
project's workspace (resolve_project_collab). Source-content routes remain
owner-scoped until #127.
GET /collaboration/api/projects/<project_id>/comments — owner or active
member of the workspace
Query params: page, per_page. Response 200 with items (oldest first),
total, page, per_page.
POST /collaboration/api/projects/<project_id>/comments — owner or active
member (comment)
Request:
{"content": "Great work on the search module. @bob please review.",
"parent_id": null}contentrequired (stripped), maxCOMMENT_MAX_LENGTHchars.parent_id(optional) must reference a comment in the same project.@usernamementions notify the mentioned active members (never the author).- A
comment.addedactivity event is recorded.
Response 201 with the comment:
{
"id": 8,
"project_id": 45,
"author_id": 1,
"author_username": "alice",
"parent_id": null,
"content": "Great work on the search module. @bob please review.",
"created_at": "2026-08-15T09:30:00+00:00",
"updated_at": null
}DELETE /collaboration/api/projects/<project_id>/comments/<comment_id> —
the author or the workspace owner
403for anyone else;404if the comment is not in this project.
Response 200: {"ok": true}.
GET /collaboration/api/workspaces/<workspace_id>/settings — any member
Response 200:
{"workspace_id": 3, "invitations_enabled": true,
"default_member_role": "viewer",
"created_at": "2026-08-01T09:00:00+00:00", "updated_at": null}PUT /collaboration/api/workspaces/<workspace_id>/settings — owner
(manage_settings)
Request (any subset): {"invitations_enabled": false, "default_member_role": "contributor"}.
400ifdefault_member_roleis notviewerorcontributor.
Response 200 with the full settings row. A settings.changed audit event is
recorded. invitations_enabled = false blocks new invitation creation
(403).
POST /collaboration/api/workspaces/<workspace_id>/heartbeat — any member
(heartbeat)
Updates the member's last_seen_at. Per-user rate limited (429 on excess).
Response 200: {"ok": true}.
| Path | Role | Description |
|---|---|---|
GET /collaboration/notifications |
logged-in | Notification inbox UI |
GET /collaboration/<workspace_id>/members |
member | Team/member management UI (owner manages, members read + leave) |
GET /collaboration/<workspace_id>/audit |
owner | Owner-only audit UI |
GET /collaboration/invitations/<token> |
public | Invitation landing page |
Every route above exists in the app; the checklist is maintained alongside the issue (#160). Verify with:
flask --app wsgi routes | findstr /i "collaboration"