Skip to content

Latest commit

 

History

History
253 lines (190 loc) · 10.4 KB

projects.md

File metadata and controls

253 lines (190 loc) · 10.4 KB

API: Projects

CRUD and browse for projects. Sub-resources (members, updates, buzz, help-wanted) have their own files; this file covers the project itself plus the directory browse.

See data-model.md for the entity shape, behaviors/project-stages.md for stage rules, and behaviors/tags.md for tag filtering.

Endpoints

Method Path Auth Summary
GET /api/projects public List/browse projects with filters and sort.
GET /api/projects/:slug public Fetch a single project.
POST /api/projects user Create a new project (caller becomes maintainer).
PATCH /api/projects/:slug maintainer | staff Update fields.
DELETE /api/projects/:slug staff Soft-delete a project.
POST /api/projects/:slug/restore staff Undo a soft-delete.
POST /api/projects/:slug/change-maintainer maintainer | staff Reassign the maintainer to another existing member.

GET /api/projects

Query parameters

Param Type Notes
q string Free-text search across title, summary, overview; the last word matches as a prefix, so it serves find-as-you-type (SQLite FTS5 in-memory index — see behaviors/storage.md). Results in relevance order.
stage enum commenting | bootstrapping | prototyping | testing | maintaining | drifting | hibernating
stageIn csv-of-enum Multi-value filter.
tag string Tag handle in <namespace>.<slug> form (e.g., tech.flutter). Repeatable; OR within namespace, AND across namespaces. ?tag=topic.transit&tag=topic.mapping&tag=tech.python returns projects that are tagged (topic.transit OR topic.mapping) AND tech.python. Matches the conventional faceted-search behavior — within a facet group users widen, across groups they narrow.
maintainer string Person slug.
memberSlug string Person slug — returns projects where this person is in project-memberships.
helpWanted bool If true, only projects with at least one helpWantedRoles.status = 'open'.
featured bool If true, only projects with featured = true. Used by the home page.
sort sort spec Default -updatedAt, except that a request with q and no sort lists in relevance order. Allowed keys: createdAt, updatedAt, title, stage (stage ordered by project-stages.md rank), activity — the project's most recent sign of life: the latest of an RSVP naming it (updatedAt), a project update (createdAt), or a buzz item logged (createdAt, not the article's publishedAt). Projects with no activity sort after every project that has some, in either direction, then by -updatedAt. Used by the home page to top up "Join a Project".
page, perPage int See conventions.md.

Soft-deleted projects (deletedAt is not null) are excluded for non-staff and included for staff. Staff filter: ?includeDeleted=true.

Response — 200

{
  "success": true,
  "data": [ProjectListItem, ...],
  "metadata": {
    "page": 1, "perPage": 30, "totalItems": 268, "totalPages": 9,
    "facets": {
      "byTopic":  [{ "tag": "topic.transit",   "title": "Transit",   "count": 28 }, ...],
      "byTech":   [...],
      "byEvent":  [...],
      "byStage":  [{ "stage": "prototyping", "count": 41 }, ...]
    }
  }
}

The facets object replaces laddr's projectsTags.{byTopic,byTech,byEvent} and projectsStages. Top 10 per facet group; full list via api/tags.md.

Counts reflect the filtered corpus with self-namespace exclusion:

  • For byTopic, byTech, byEvent — count over the project set filtered by every active criterion except tag filters in that same namespace. So if the user has selected topic.transit, the topic facet still shows other topics (with counts narrowed by the rest of the filters) so they can widen their topic selection without losing track. Selecting another topic adds to the topic set (OR within namespace).
  • For byStage — same pattern, count over the set filtered by everything except stage / stageIn.

Active selections that fall below the top 10 in their facet group are pinned into the response so the SPA can render them as still-selected. Counts on the pinned entries reflect the same self-exclusion rule.

Counts are honest about the search the user is composing — "if you click this, this is how many projects you'd see" for OR-widening within the group, and a stable reference for what's still available across the rest of the corpus.

ProjectListItem shape

The summary shape used in lists; smaller than the detail response.

{
  "id": "<uuid>",
  "slug": "squadquest",
  "title": "SquadQuest",
  "summary": "Realtime community events without Facebook.",
  "stage": "testing",
  "overviewExcerpt": "SquadQuest is a different kind of civic technology...",  // <= 600 chars, plain text (markdown stripped)
  "maintainer": { "slug": "chris", "fullName": "Chris Alfano", "avatarUrl": "..." } | null,
  "memberCount": 5,
  "members": [PersonAvatar, ...],                                            // first 10 by role then alpha
  "links": {
    "usersUrl": "https://squadquest.app",
    "developersUrl": "https://github.com/SquadQuest/SquadQuest",
    "chatChannel": "squadquest"
  },
  "openHelpWantedCount": 2,
  "tags": [{ "namespace": "tech", "slug": "flutter", "title": "Flutter" }, ...],
  "updatedAt": "2026-04-02T14:11:00Z"
}

GET /api/projects/:slug

Fetches a single project by slug.

Response — 200

{
  "success": true,
  "data": Project
}

Project shape

{
  "id": "<uuid>",
  "slug": "squadquest",
  "title": "SquadQuest",
  "summary": "...",
  "overview": "## Markdown source ...",
  "overviewHtml": "<h2>Markdown source ...",   // sanitized HTML rendered server-side
  "stage": "testing",
  "stageProgress": 0.5,                       // derived; see project-stages.md
  "maintainer": Person | null,
  "memberships": [ProjectMembership, ...],    // includes all members
  "openHelpWantedRoles": [HelpWantedRole, ...],
  "tags": { "topic": [Tag, ...], "tech": [Tag, ...], "event": [Tag, ...] },
  "links": { "usersUrl": "...", "developersUrl": "...", "chatChannel": "..." },
  "counts": { "updates": 12, "buzz": 3, "members": 5 },
  "permissions": {
    "canEdit": true,
    "canManageMembers": false,
    "canPostUpdate": true,
    "canLogBuzz": true,
    "canPostHelpWanted": true,
    "canDelete": false
  },
  "createdAt": "...",
  "updatedAt": "...",
  "deletedAt": "..." | null
}

permissions is the current caller's permissions on this project — the frontend uses it to decide which actions to render. The server still enforces the same rules on each mutation endpoint.

deletedAt is null for active projects. It is non-null only when a soft-deleted project is fetched by staff (non-staff get 404), so the SPA can render the soft-delete banner with a Restore action.

Errors

  • 404 not_found — slug doesn't match (or is soft-deleted and caller can't see deleted).

POST /api/projects

Create a new project. Caller becomes the maintainer, is added as a Founder membership, and is recorded as the project's createdById (never changed afterwards; not part of any request or update).

Request

{
  "title": "My New Project",
  "slug": "my-new-project",     // optional; derived from title if omitted
  "summary": "Tagline (optional)",
  "overview": "Markdown source",
  "usersUrl": "https://...",
  "developersUrl": "https://...",
  "chatChannel": "my-channel",
  "stage": "commenting",         // optional; default 'commenting'
  "tags": {                      // optional
    "topic": ["transit", "mapping"],
    "tech":  ["typescript"],
    "event": []
  }
}

Tags reference existing tag slugs by namespace + slug. Unknown tag slugs auto-create new tags only when the caller has accountLevel ∈ {staff, administrator}; otherwise unknown tags return 422 with a hint to ask staff to add them.

Response — 201

{ "success": true, "data": Project }

Errors

  • 422 validation_failed
  • 409 conflict — slug already taken

PATCH /api/projects/:slug

Update one or more fields. Field-level. Omitted fields are unchanged. Tags, if present, fully replace the existing set within each namespace included in the request.

Allowed fields by role

Field maintainer staff
title, summary, overview, usersUrl, developersUrl, chatChannel ✓ ✓
stage ✓ ✓
tags.{topic,tech,event} ✓ ✓
slug – ✓
featured, featuredImageKey – ✓
maintainerId – – (use change-maintainer)

Response — 200

{ "success": true, "data": Project }

Errors

  • 403 forbidden — caller is not maintainer or staff
  • 422 validation_failed
  • 409 conflict — slug change collides

DELETE /api/projects/:slug

Soft-delete. Sets deletedAt = now(). Removes the project from public lists, leaves its row and sub-resources intact for restore.

Response — 204

Errors

  • 403 forbidden
  • 404 not_found

POST /api/projects/:slug/restore

Unset deletedAt.

Response — 200

{ "success": true, "data": Project }

POST /api/projects/:slug/change-maintainer

Request

{ "personSlug": "another-member" }

The new maintainer must already be a member (a project-memberships record exists for them). If not, respond 409 conflict with error.code = "not_a_member".

The previous maintainer remains a member with their old role (or "Maintainer (former)" if their role was previously null).

Response — 200

{ "success": true, "data": Project }

Notes

  • Slug changes create a 301 redirect from the old slug for 90 days (handled at the web layer, not the API). The API itself accepts the new slug only after the change. See behaviors/slug-handles.md.
  • Overview rendering happens server-side via the markdown pipeline in behaviors/markdown-rendering.md. The overviewHtml field is always derived from overview on read.
  • Stage transitions are free-form in v1 — any role permitted to edit can set any stage. We don't enforce ordering. See behaviors/project-stages.md for the rationale and what we want eventually.