Manage projects, members and services through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

/api/v1/projects, /members and /services: create, list, show, update,
delete and restore, with the panel's own rules — so the MCP server can
load the collective's work, people and offer without anyone retyping
them into forms. The MCP server picks the tools up from openapi.yaml.

- Relations travel as ids and are replaced whole when sent, left alone
  when not: a project's technology_ids, service_ids and members with
  each one's role; a member's technology_ids; a service's
  technology_ids and project_ids. Record and relations are saved in
  one transaction.
- Publishing stays a permission of its own. Without projects.publish
  or services.publish, published_at, unpublish_at and is_published are
  refused — the fields the panel disables for the same people.
- MemberPolicy still lets someone edit their own profile.
- Uploads accept `for: projects` and `for: members`, landing where the
  panel's own upload fields put them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:07:20 +02:00
parent 6b67c7afd4
commit 82ef2b38dd
19 changed files with 1660 additions and 1 deletions

View File

@@ -106,6 +106,12 @@ tags:
description: Translatable page content
- name: Categories
description: Translatable page categories
- name: Projects
description: The collective's work — translatable, with its team and stack
- name: Members
description: The people in the collective
- name: Services
description: What the collective offers
- name: Technologies
description: What the collective uses, and its place on the tech radar
- name: Users
@@ -1279,6 +1285,366 @@ paths:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects:
get:
tags: [Projects]
summary: List projects
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
schema: { type: string, enum: [idea, active, paused, completed, archived] }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated projects
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Projects]
summary: Create a project
description: |
Relations are sent as ids and replaced whole when present:
`technology_ids`, `service_ids`, and `members` with each one's role.
`published_at` and `unpublish_at` require `projects.publish`. For a
cover, upload it with `POST /v1/media` and `for: projects`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectWrite" }
responses:
"201":
description: Project created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}:
parameters:
- name: project
in: path
required: true
schema: { type: integer }
get:
tags: [Projects]
summary: Show a project
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The project
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Projects]
summary: Update a project
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectWrite" }
responses:
"200":
description: Project updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Projects]
summary: Soft-delete a project
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/restore:
post:
tags: [Projects]
summary: Restore a soft-deleted project
parameters:
- name: project
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Project restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members:
get:
tags: [Members]
summary: List members
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: search
in: query
description: Matches the name.
schema: { type: string }
- name: is_public
in: query
schema: { type: boolean }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated members
content:
application/json:
schema: { $ref: "#/components/schemas/MemberCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Members]
summary: Create a member
description: |
`links` is a label → URL map. `technology_ids` is replaced whole when
present; which projects someone is on is set from the project, where
the role belongs. For a photo, upload it with `POST /v1/media` and
`for: members`. Someone may edit their own profile without
`members.update`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MemberWrite" }
responses:
"201":
description: Member created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/members/{member}:
parameters:
- name: member
in: path
required: true
schema: { type: integer }
get:
tags: [Members]
summary: Show a member
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The member
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Members]
summary: Update a member
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MemberWrite" }
responses:
"200":
description: Member updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Members]
summary: Soft-delete a member
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members/{member}/restore:
post:
tags: [Members]
summary: Restore a soft-deleted member
parameters:
- name: member
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Member restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services:
get:
tags: [Services]
summary: List services
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: is_published
in: query
schema: { type: boolean }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated services
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Services]
summary: Create a service
description: |
`technology_ids` and `project_ids` are replaced whole when present.
`is_published`, `published_at` and `unpublish_at` require
`services.publish`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceWrite" }
responses:
"201":
description: Service created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}:
parameters:
- name: service
in: path
required: true
schema: { type: integer }
get:
tags: [Services]
summary: Show a service
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The service
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Services]
summary: Update a service
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceWrite" }
responses:
"200":
description: Service updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Services]
summary: Soft-delete a service
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/restore:
post:
tags: [Services]
summary: Restore a soft-deleted service
parameters:
- name: service
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Service restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media:
post:
tags: [Media]
@@ -2295,9 +2661,220 @@ components:
nullable: true
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
Project:
type: object
properties:
id: { type: integer }
locale: { type: string, description: The language the fields below were resolved into. }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
tagline: { type: string, nullable: true }
description: { type: string, nullable: true, description: HTML from the panel's rich editor. }
decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. }
postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
cover_image_alt: { type: string, nullable: true }
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, nullable: true, format: uri }
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
cover_image_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. }
unpublish_at: { type: string, format: date-time, nullable: true }
position: { type: integer }
client_id: { type: integer, nullable: true }
lead_member_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
service_ids: { type: array, items: { type: integer } }
members:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
role: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
ProjectWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
tagline: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
decisions: { $ref: "#/components/schemas/TranslatedField" }
postmortem: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
cover_image_alt: { $ref: "#/components/schemas/TranslatedField" }
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, format: uri, nullable: true }
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
position: { type: integer, minimum: 0 }
client_id: { type: integer, nullable: true }
lead_member_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
service_ids: { type: array, items: { type: integer } }
members:
type: array
description: The whole team, replacing the previous one.
items:
type: object
required: [id]
properties:
id: { type: integer }
role: { type: string, nullable: true, maxLength: 120 }
ProjectCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Project" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Member:
type: object
properties:
id: { type: integer }
locale: { type: string }
name: { type: string }
slug: { type: string }
headline: { type: string, nullable: true }
bio: { type: string, nullable: true, description: Plain text. }
avatar: { type: string, nullable: true, description: The stored path. }
avatar_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
email: { type: string, nullable: true, description: Never shown on the site. }
github_username: { type: string, nullable: true }
links: { type: object, additionalProperties: { type: string }, description: Label → URL. }
is_public: { type: boolean }
is_founder: { type: boolean }
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
position: { type: integer }
technology_ids: { type: array, items: { type: integer } }
projects:
type: array
items:
type: object
properties:
id: { type: integer }
title: { type: string, nullable: true }
role: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
MemberWrite:
type: object
required: [name, slug]
properties:
name: { type: string, maxLength: 255 }
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
headline: { $ref: "#/components/schemas/TranslatedField" }
bio: { $ref: "#/components/schemas/TranslatedField" }
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. }
email: { type: string, format: email, nullable: true }
github_username: { type: string, nullable: true, maxLength: 120 }
links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } }
is_public: { type: boolean }
is_founder: { type: boolean }
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
position: { type: integer, minimum: 0 }
technology_ids: { type: array, items: { type: integer } }
MemberCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Member" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Service:
type: object
properties:
id: { type: integer }
locale: { type: string }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
tagline: { type: string, nullable: true }
description: { type: string, nullable: true, description: Plain text. }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
icon: { type: string, nullable: true }
is_featured: { type: boolean }
is_published: { type: boolean }
published_at: { type: string, format: date-time, nullable: true }
unpublish_at: { type: string, format: date-time, nullable: true }
position: { type: integer }
technology_ids: { type: array, items: { type: integer } }
project_ids: { type: array, items: { type: integer } }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
ServiceWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
tagline: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." }
is_featured: { type: boolean }
position: { type: integer, minimum: 0 }
is_published: { type: boolean, description: Requires `services.publish`. }
published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
technology_ids: { type: array, items: { type: integer } }
project_ids: { type: array, items: { type: integer } }
ServiceCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Service" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor:
type: string
enum: [pages, technologies]
enum: [pages, technologies, projects, members]
default: pages
description: The kind of record the image is for.