issue_count, done_count) so the FE can render burn-down without a separate query. List and create are nested under the owning project; update and delete address the milestone id directly.
Implementation: internal/api/milestone_handler.go. Backed by the milestones table; issue counts are computed from missions WHERE mission_type = 'issue'.
All endpoints require an authenticated session and workspace context. The URL space is split:
- List / Create are nested under the project:
/api/v1/projects/{projectId}/milestones. The project must belong to the calling workspace (projects.workspace_id), otherwise404. - Update / Delete address the milestone directly:
/api/v1/milestones/{milestoneId}. Workspace ownership is verified by joiningmilestones → projects → workspace_id.
Endpoints
Milestone shape
GET /api/v1/projects/{projectId}/milestones
Request: projectId is a required path parameter. No request body.
List milestones in a project, ordered by position ASC, created_at ASC.
Auth: authenticated session + workspace context.
Response: 200 OK — JSON array (never null).
LEFT JOIN against an aggregated subquery so milestones with zero issues still appear (counts come back as 0, not null).
POST /api/v1/projects/{projectId}/milestones
Create a new milestone in the project. Position is auto-assigned at the end of the project’s milestone list.
Auth: authenticated session + workspace context + OWNER, ADMIN, or MANAGER role (requireRole("create")).
Request body:
Response:
201 Created with the milestone object (issue_count: 0, done_count: 0).
WebSocket event: milestone.created broadcast on the workspace channel with { "id": "<id>", "project_id": "<projectId>" }.
PATCH /api/v1/milestones/{milestoneId}
Partial update. Workspace ownership is verified by joining milestones → projects → workspace_id — a cross-workspace id returns 404, not 403, so the surface can’t be probed for which milestone ids exist.
Auth: authenticated session + workspace context + OWNER, ADMIN, or MANAGER role.
Request body: every field optional.
Response:
200 OK with the full updated milestone object (rollup counts are recomputed inline via correlated subqueries).
WebSocket event: milestone.updated broadcast on the workspace channel with { "id": "<id>", "project_id": "<projectId>" }.
DELETE /api/v1/milestones/{milestoneId}
Request: milestoneId is a required path parameter. No request body.
Auth: authenticated session + workspace context + OWNER or ADMIN role (requireRole("manage")).
Response: 204 No Content.
WebSocket event: milestone.deleted broadcast on the workspace channel with { "id": "<id>", "project_id": "<projectId>" }.
See also
- Issues —
milestone_idon a mission attaches it here; delete unlinks instead of cascading. - Recurring Issues — can target a milestone via
milestone_idso every fire lands in the right bucket. - Crews — projects belong to a workspace, not a crew, but crews are the runtime container for the missions filed against the milestone.