diff --git a/README.md b/README.md index 4098626..c463309 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,11 @@ pass on the server: | Charlas | `/talks?limit=6` | | Contact form | `POST /contact` | +A project page also asks TaskStream, when the project has a board there +(`taskstream.showcase_url` in its payload): roadmap, releases, the sprint under +way and tickets labelled `good-first-issue`. It is public, answers within four +seconds or the page goes without those sections, and its links open the board. + `landing/openapi.yaml` is the specification the app was written against, and `landing/src/app/core/api.types.ts` is the API's own generated type file. Both are copies — refresh them from the API when the contract moves: diff --git a/landing/openapi.yaml b/landing/openapi.yaml index 0bbb129..375da4e 100644 --- a/landing/openapi.yaml +++ b/landing/openapi.yaml @@ -16,9 +16,8 @@ info: ## Authentication Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued - by the **Pireon identity server** (`https://oauth.pireon.dev`). This - application does not issue tokens of its own; it validates the ones the - identity server issued. + by the **Pireon identity server** (`https://oauth.pireon.dev`), which this + application validates — or a **machine token** of its own. Obtain a token through `POST /v1/auth/token`, which proxies the identity server's password grant. The panel's own sign-in form uses the same grant, @@ -26,6 +25,13 @@ info: against `https://oauth.pireon.dev/oauth/authorize` remains available for clients that prefer never to handle a password. + Machine tokens (`pmt_…`) are for scripts and integrations: a person makes + one with `POST /v1/tokens`, naming the permissions it gets — never more + than their own — and when it expires. It acts as that person within + those permissions, checked on every request, so taking a role away from + them narrows their tokens at once. It is checked here and never sent to + the identity server. + ## Authorisation The identity server establishes *who* the caller is. What they may *do* is @@ -106,8 +112,46 @@ 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: Clients + description: The organisations the work was for + - name: Testimonials + description: What the people the collective worked with said + - name: Talks + description: Talks given elsewhere, by members of the collective + - name: Calls + description: Open calls for contributors + - name: Applications + description: The inbox — what people sent from the public site + - name: Technologies + description: What the collective uses, and its place on the tech radar + - name: Redirects + description: Old addresses, and the missing ones the frontend reported + - name: Repositories + description: The collective's git repositories and their synced numbers + - name: Settings + description: Runtime settings and the public site's maintenance mode + - name: History + description: Who changed what and when, and undoing a change + - name: Reviews + description: Review, publication and preview links + - name: Health + description: What is wrong with the site, and what is left to translate + - name: Batch + description: Several operations in one, and importing records from JSON or CSV + - name: Webhooks + description: Telling other systems what happened here + - name: Audit + description: Who did what, when, and how they came in - name: Users description: Local user administration + - name: Media + description: The media library, and images uploaded for a record to point at - name: Roles description: Local authorisation model (read-only) @@ -235,6 +279,81 @@ paths: description: Null applies in every language. "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/search: + get: + tags: [Public] + summary: Search the site + description: | + What the site shows that matches some words, in the request's + language, best match first: published pages, projects and services, + open calls, public members, and technologies in use. + + Full-text in production — words are stemmed in the language searched, + so "proyectos" finds "proyecto", and a title match ranks above one in + the body. `path` is where the result lives on the site; null for a + technology, which has no address on this side. + security: [] + parameters: + - $ref: "#/components/parameters/Lang" + - name: q + in: query + required: true + schema: { type: string, minLength: 2, maxLength: 120 } + - name: type[] + in: query + description: "Limit to some kinds: `page`, `project`, `service`, `call`, `member`, `technology`." + schema: + type: array + items: { type: string, enum: [page, project, service, call, member, technology] } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 50, default: 20 } + responses: + "200": + description: Results + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PublicSearchResult" } + meta: + type: object + properties: + query: { type: string } + locale: { type: string } + count: { type: integer } + "422": { $ref: "#/components/responses/ValidationError" } + "503": { $ref: "#/components/responses/Maintenance" } + + /public/v1/tags: + get: + tags: [Public] + summary: Blog tags in use + description: Tags on something published, with how many posts each has in the request's language. Most used first. + security: [] + parameters: + - $ref: "#/components/parameters/Lang" + responses: + "200": + description: The tags + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + name: { type: string } + slug: { type: string } + count: { type: integer } + "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/projects: get: tags: [Public] @@ -778,6 +897,10 @@ paths: - name: category in: query schema: { type: string } + - name: tag + in: query + description: A tag's slug in the active language. + schema: { type: string } - name: featured in: query schema: { type: boolean } @@ -909,8 +1032,9 @@ paths: description: | Proxies the identity server's `password` and `refresh_token` grants so first-party clients need not embed the OAuth server's URL or secret. - Rate limited to 6 requests per minute. Browser clients should use - Authorization Code + PKCE instead. + Rate limited to 6 requests per minute per account and address, and 30 + per address overall. Browser clients should use Authorization Code + + PKCE instead. security: [] requestBody: required: true @@ -945,6 +1069,71 @@ paths: data: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } + /v1/tokens: + get: + tags: [Auth] + summary: List my machine tokens + description: Newest first, revoked and expired ones included. A super admin may ask for everyone's with `all`. Not available to a machine token. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: all + in: query + description: "Everyone's tokens. Super admins only." + schema: { type: boolean } + responses: + "200": + description: Paginated tokens + content: + application/json: + schema: { $ref: "#/components/schemas/ApiTokenCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Auth] + summary: Make a machine token + description: | + `abilities` are permission names — `pages.update` — or a whole + resource — `pages.*` — and only ones the caller holds. It expires + after 90 days unless `expires_at` says otherwise, a year at most. The + token itself is in the answer's `token`, and nowhere else ever again. + Not available to a machine token. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, abilities] + properties: + name: { type: string, maxLength: 120, example: Despliegue continuo } + abilities: { type: array, items: { type: string }, example: ["technologies.*", "media.view_any"] } + expires_at: { type: string, format: date-time } + responses: + "201": + description: Token made + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ApiToken" } + token: { type: string, description: The token. Shown once. } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/tokens/{token}: + delete: + tags: [Auth] + summary: Revoke a machine token + description: It stops working at once and stays listed as revoked. Your own, or anyone's for a super admin. + parameters: + - name: token + in: path + required: true + schema: { type: integer } + responses: + "204": { description: Revoked } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/pages: get: tags: [Pages] @@ -1171,6 +1360,3157 @@ paths: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } + /v1/tags: + get: + tags: [Categories] + summary: List blog tags + description: Every tag, unpaginated, with how many pages carry it. Kept by whoever keeps the categories. + responses: + "200": + description: The tags + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Categories] + summary: Create a blog tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "201": + description: Tag created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/tags/{tag}: + parameters: + - name: tag + in: path + required: true + schema: { type: integer } + patch: + tags: [Categories] + summary: Update a blog tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "200": + description: Tag updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Categories] + summary: Delete a blog tag + description: The pages keep existing; they just stop carrying it. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/technologies: + get: + tags: [Technologies] + summary: List technologies + description: Ordered by `position`, then name. + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: category + in: query + schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } + - name: radar_ring + in: query + description: '`none` for the technologies kept off the radar.' + schema: { type: string, enum: [adopt, trial, assess, hold, none] } + - name: search + in: query + description: Matches the name. + schema: { type: string } + responses: + "200": + description: Paginated technologies + content: + application/json: + schema: { $ref: "#/components/schemas/TechnologyCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Technologies] + summary: Create a technology + description: | + For a logo, upload it first with `POST /v1/media` and `for: + technologies`, and send the `path` it answers with as `logo`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TechnologyWrite" } + responses: + "201": + description: Technology created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Technology" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/technologies/{technology}: + parameters: + - name: technology + in: path + required: true + schema: { type: integer } + get: + tags: [Technologies] + summary: Show a technology + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The technology + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Technology" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Technologies] + summary: Update a technology + description: | + Partial update. `description` and `radar_note` merge into the existing + translations; send `radar_ring: null` to take it off the radar. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TechnologyWrite" } + responses: + "200": + description: Technology updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Technology" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Technologies] + summary: Delete a technology + description: Refused with 403 while a project, member or call still uses it. + responses: + "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`. + + With `taskstream: true` the project also gets a **public** board in + TaskStream, the collective's issue tracker, and the answer carries its + link in `taskstream_url`. Its key is `taskstream_key`, or one made from + the slug in the default language (one word: its first ten characters; + several: their initials). The board is opened first: if TaskStream + refuses it (a key in use, say), nothing is created here either and the + reason comes back as a validation error. It is opened as the caller, + whose Pireon token is passed on; a machine token cannot ask for one + unless the server has a `TASKSTREAM_TOKEN` of its own. + 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" } + "502": + description: TaskStream did not answer; nothing was created. + + /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/clients: + get: + tags: [Clients] + summary: List clients + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_public + in: query + schema: { type: boolean } + - name: search + in: query + description: Matches the name. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated clients + content: + application/json: + schema: { $ref: "#/components/schemas/ClientCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Clients] + summary: Create a client + description: | + Send the `path` from `POST /v1/media` with `for=clients` as `logo`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ClientWrite" } + responses: + "201": + description: Client created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/clients/{client}: + parameters: + - name: client + in: path + required: true + schema: { type: integer } + get: + tags: [Clients] + summary: Show a client + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The client + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Clients] + summary: Update a client + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ClientWrite" } + responses: + "200": + description: Client updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Clients] + summary: Soft-delete a client + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/clients/{client}/restore: + post: + tags: [Clients] + summary: Restore a soft-deleted client + parameters: + - name: client + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Client restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Client" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/testimonials: + get: + tags: [Testimonials] + summary: List testimonials + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_public + in: query + schema: { type: boolean } + - name: client_id + in: query + schema: { type: integer } + - name: project_id + in: query + schema: { type: integer } + - name: service_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the author's name. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated testimonials + content: + application/json: + schema: { $ref: "#/components/schemas/TestimonialCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Testimonials] + summary: Create a testimonial + description: | + `quote` holds what the person said in `original_locale`, and any + translations of it. Send the `path` from `POST /v1/media` with + `for=testimonials` as `avatar`. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TestimonialWrite" } + responses: + "201": + description: Testimonial created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/testimonials/{testimonial}: + parameters: + - name: testimonial + in: path + required: true + schema: { type: integer } + get: + tags: [Testimonials] + summary: Show a testimonial + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The testimonial + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Testimonials] + summary: Update a testimonial + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TestimonialWrite" } + responses: + "200": + description: Testimonial updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Testimonials] + summary: Soft-delete a testimonial + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/testimonials/{testimonial}/restore: + post: + tags: [Testimonials] + summary: Restore a soft-deleted testimonial + parameters: + - name: testimonial + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Testimonial restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Testimonial" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/talks: + get: + tags: [Talks] + summary: List talks + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: is_public + in: query + schema: { type: boolean } + - name: member_id + in: query + schema: { type: integer } + - name: project_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the title or the event. + schema: { type: string } + responses: + "200": + description: Paginated talks + content: + application/json: + schema: { $ref: "#/components/schemas/TalkCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Talks] + summary: Create a talk + description: | + `title` is the one the talk was given under, in `language`. Only + `summary` is translated. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TalkWrite" } + responses: + "201": + description: Talk created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Talk" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/talks/{talk}: + parameters: + - name: talk + in: path + required: true + schema: { type: integer } + get: + tags: [Talks] + summary: Show a talk + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The talk + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Talk" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Talks] + summary: Update a talk + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TalkWrite" } + responses: + "200": + description: Talk updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Talk" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Talks] + summary: Delete a talk + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls: + get: + tags: [Calls] + summary: List calls + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`open`, `filled` or `closed`." + schema: { type: string } + - name: project_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the title within the active language only. + schema: { type: string } + responses: + "200": + description: Paginated calls + content: + application/json: + schema: { $ref: "#/components/schemas/CallCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Calls] + summary: Create a call + description: | + `technology_ids` is replaced whole when present. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CallWrite" } + responses: + "201": + description: Call created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Call" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/calls/{call}: + parameters: + - name: call + in: path + required: true + schema: { type: integer } + get: + tags: [Calls] + summary: Show a call + parameters: + - $ref: "#/components/parameters/Lang" + - $ref: "#/components/parameters/With" + responses: + "200": + description: The call + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Call" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Calls] + summary: Update a call + description: Partial update. Translatable fields merge into the existing map. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CallWrite" } + responses: + "200": + description: Call updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Call" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Calls] + summary: Delete a call + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications: + get: + tags: [Applications] + summary: List applications + parameters: + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`new`, `contacted`, `accepted`, `declined` or `spam`. Without it, spam is left out." + schema: { type: string } + - name: kind + in: query + description: "`contribute` or `request`." + schema: { type: string } + - name: call_id + in: query + schema: { type: integer } + - name: search + in: query + description: Matches the name, e-mail or organisation. + schema: { type: string } + - name: trashed + in: query + schema: { type: boolean } + responses: + "200": + description: Paginated applications + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}: + parameters: + - name: application + in: path + required: true + schema: { type: integer } + get: + tags: [Applications] + summary: Show an application + responses: + "200": + description: The application + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Applications] + summary: Handle an application + description: | + Only how it is being handled changes: `status` and the private + `notes`. Moving it to `contacted`, `accepted` or `declined` records who + did it and when; back to `new` clears both. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationWrite" } + responses: + "200": + description: Application updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Applications] + summary: Soft-delete an application + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}/restore: + post: + tags: [Applications] + summary: Restore a soft-deleted application + parameters: + - name: application + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Application restored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/redirects: + get: + tags: [Redirects] + summary: List redirects + parameters: + - $ref: "#/components/parameters/PerPage" + - name: is_enabled + in: query + schema: { type: boolean } + - name: locale + in: query + schema: { type: string } + - name: automatic + in: query + description: "True for the ones a slug rename left behind, false for those typed by hand." + schema: { type: boolean } + - name: search + in: query + description: "Matches either end." + schema: { type: string } + responses: + "200": + description: Paginated redirects + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Redirects] + summary: Create a redirect + description: | + Both ends are normalised — leading slash, no trailing one, no query + string. A `locale` of null applies in every language. Creating one marks + the matching missing URLs as resolved. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectWrite" } + responses: + "201": + description: Redirect created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/redirects/{redirect}: + parameters: + - name: redirect + in: path + required: true + schema: { type: integer } + get: + tags: [Redirects] + summary: Show a redirect + responses: + "200": + description: The redirect + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Redirects] + summary: Update a redirect + description: Partial update. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectWrite" } + responses: + "200": + description: Redirect updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Redirects] + summary: Delete a redirect + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/missing-urls: + get: + tags: [Redirects] + summary: List missing urls + parameters: + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`pending` (the default), `resolved` or `ignored`." + schema: { type: string } + - name: locale + in: query + schema: { type: string } + - name: search + in: query + description: "Matches the path." + schema: { type: string } + responses: + "200": + description: Paginated missing URLs + content: + application/json: + schema: { $ref: "#/components/schemas/MissingUrlCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/missing-urls/{missing_url}: + parameters: + - name: missing_url + in: path + required: true + schema: { type: integer } + get: + tags: [Redirects] + summary: Show a missing URL + responses: + "200": + description: The missing URL + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/MissingUrl" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Redirects] + summary: Ignore a missing URL, or stop ignoring it + description: "`status` moves between `pending` and `ignored` only: `resolved` is what giving it a redirect does." + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MissingUrlWrite" } + responses: + "200": + description: MissingUrl updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/MissingUrl" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Redirects] + summary: Delete a missing URL + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/missing-urls/{missing_url}/redirect: + post: + tags: [Redirects] + summary: Give a missing URL a destination + description: | + Creates the redirect from its path, which marks it resolved. `locale` + defaults to the language it was seen in; send null for every language. + parameters: + - name: missing_url + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [to] + properties: + to: { type: string, maxLength: 500 } + status: { type: integer, enum: [301, 302], default: 301 } + locale: { type: string, nullable: true } + responses: + "201": + description: Redirect created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Redirect" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/repositories: + get: + tags: [Repositories] + summary: List repositories + parameters: + - $ref: "#/components/parameters/PerPage" + - name: provider + in: query + description: "`gitea`, `github` or `gitlab`." + schema: { type: string } + - name: project_id + in: query + schema: { type: integer } + - name: show_publicly + in: query + schema: { type: boolean } + - name: failing + in: query + description: "Only those whose sync has failed three times or more." + schema: { type: boolean } + - name: search + in: query + description: "Matches the full name." + schema: { type: string } + responses: + "200": + description: Paginated repositories + content: + application/json: + schema: { $ref: "#/components/schemas/RepositoryCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Repositories] + summary: Create a repository + description: | + Only which repository, whose project and whether to show it. Everything + else is what the provider reports on the next sync. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RepositoryWrite" } + responses: + "201": + description: Repository created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/repositories/{repository}: + parameters: + - name: repository + in: path + required: true + schema: { type: integer } + get: + tags: [Repositories] + summary: Show a repository + responses: + "200": + description: The repository + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Repositories] + summary: Update a repository + description: Partial update. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RepositoryWrite" } + responses: + "200": + description: Repository updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Repositories] + summary: Delete a repository + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/repositories/{repository}/sync: + post: + tags: [Repositories] + summary: Sync a repository + description: | + Queues a sync with the provider and answers straight away. Requires + `repositories.sync`: a sync spends a rate limit the whole application + shares. + parameters: + - name: repository + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Sync queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Repository" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/settings: + get: + tags: [Settings] + summary: List settings + description: Every stored setting, unpaginated. Requires `settings.view`. + responses: + "200": + description: The settings + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Setting" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/settings/{setting}: + parameters: + - name: setting + in: path + required: true + description: "The setting's key, e.g. `site.maintenance`." + schema: { type: string } + get: + tags: [Settings] + summary: Show a setting + responses: + "200": + description: The setting + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Setting" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Settings] + summary: Update a setting + description: | + Replaces the value whole. Requires `settings.update`, or + `settings.maintenance` for `site.maintenance`. `content.version` is + written by the application and refused here. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [value] + properties: + value: { description: "Any JSON value, null included." } + responses: + "200": + description: Setting updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Setting" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/maintenance: + get: + tags: [Settings] + summary: Show maintenance mode + description: The public site's maintenance mode. Requires `settings.view`. + responses: + "200": + description: Maintenance state + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Maintenance" } + "403": { $ref: "#/components/responses/Forbidden" } + put: + tags: [Settings] + summary: Switch maintenance mode + description: | + Only the public site goes down; the panel and this API keep working. + `message` (language → text) and `until` are kept either way, so the + wording can be written before switching it on. A passed `until` lifts + it by itself. Requires `settings.maintenance`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: { type: boolean } + message: { type: object, nullable: true, additionalProperties: { type: string, maxLength: 500 } } + until: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Maintenance state + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Maintenance" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/revisions: + get: + tags: [History] + summary: List the history of a page + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/categories/{category}/revisions: + get: + tags: [History] + summary: List the history of a category + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: category + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/revisions: + get: + tags: [History] + summary: List the history of a project + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/members/{member}/revisions: + get: + tags: [History] + summary: List the history of a member + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: member + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/revisions: + get: + tags: [History] + summary: List the history of a service + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/clients/{client}/revisions: + get: + tags: [History] + summary: List the history of a client + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: client + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/testimonials/{testimonial}/revisions: + get: + tags: [History] + summary: List the history of a testimonial + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: testimonial + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/talks/{talk}/revisions: + get: + tags: [History] + summary: List the history of a talk + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: talk + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/technologies/{technology}/revisions: + get: + tags: [History] + summary: List the history of a technology + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: technology + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls/{call}/revisions: + get: + tags: [History] + summary: List the history of a call + description: Who changed what and when, newest first. Binned records included. + parameters: + - name: call + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: event + in: query + description: "`created`, `updated`, `published`, `scheduled`, `unpublished`, `deleted`, `restored` or `erased`." + schema: { type: string } + responses: + "200": + description: Paginated revisions + content: + application/json: + schema: { $ref: "#/components/schemas/RevisionCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/revisions/{revision}/restore: + post: + tags: [History] + summary: Undo a revision + description: | + Puts back the values one revision changed, as the panel's history + does. Restoring is itself a change and lands in the history. Requires + permission to edit the record, and to publish it when the revision + touched its publication. + parameters: + - name: revision + in: path + required: true + schema: { type: integer } + responses: + "200": + description: The revision the restore recorded + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Revision" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/reviews: + get: + tags: [Reviews] + summary: What is waiting for my review + description: | + Pages, projects and services awaiting review that the caller could + publish — addressed to them, or to nobody in particular. Oldest + request first, unpaginated. + responses: + "200": + description: The queue + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ReviewState" } + + /v1/pages/{page}/reviews: + get: + tags: [Reviews] + summary: Review thread of a page + description: Requests, returns, publications and comments, newest first. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated thread + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCommentCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/pages/{page}/comments: + post: + tags: [Reviews] + summary: Comment on the review of a page + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 2000 } + responses: + "201": + description: Comment added + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewComment" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/review: + post: + tags: [Reviews] + summary: Ask for a page to be reviewed + description: | + The way to put it live without `pages.publish`: whoever can publish it + is told. `reviewer_id` addresses one of them; without it, anyone may + answer. Refused if it is already waiting or already published. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reviewer_id: { type: integer, nullable: true } + note: { type: string, nullable: true, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/return: + post: + tags: [Reviews] + summary: Send a page back for changes + description: Tells whoever asked, with the reason. Requires `pages.publish`. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: { type: string, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/publish: + post: + tags: [Reviews] + summary: Publish a page, now or later + description: | + Approves it: live now, or from `at` when that is in the future. Any + pending review is answered either way. Requires `pages.publish`. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/pages/{page}/unpublish: + post: + tags: [Reviews] + summary: Take a page off the site + description: Requires `pages.publish`. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/reviews: + get: + tags: [Reviews] + summary: Review thread of a project + description: Requests, returns, publications and comments, newest first. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated thread + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCommentCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/comments: + post: + tags: [Reviews] + summary: Comment on the review of a project + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 2000 } + responses: + "201": + description: Comment added + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewComment" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/review: + post: + tags: [Reviews] + summary: Ask for a project to be reviewed + description: | + The way to put it live without `projects.publish`: whoever can publish it + is told. `reviewer_id` addresses one of them; without it, anyone may + answer. Refused if it is already waiting or already published. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reviewer_id: { type: integer, nullable: true } + note: { type: string, nullable: true, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/return: + post: + tags: [Reviews] + summary: Send a project back for changes + description: Tells whoever asked, with the reason. Requires `projects.publish`. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: { type: string, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/publish: + post: + tags: [Reviews] + summary: Publish a project, now or later + description: | + Approves it: live now, or from `at` when that is in the future. Any + pending review is answered either way. Requires `projects.publish`. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/projects/{project}/unpublish: + post: + tags: [Reviews] + summary: Take a project off the site + description: Requires `projects.publish`. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/reviews: + get: + tags: [Reviews] + summary: Review thread of a service + description: Requests, returns, publications and comments, newest first. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated thread + content: + application/json: + schema: { $ref: "#/components/schemas/ReviewCommentCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/comments: + post: + tags: [Reviews] + summary: Comment on the review of a service + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 2000 } + responses: + "201": + description: Comment added + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewComment" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/review: + post: + tags: [Reviews] + summary: Ask for a service to be reviewed + description: | + The way to put it live without `services.publish`: whoever can publish it + is told. `reviewer_id` addresses one of them; without it, anyone may + answer. Refused if it is already waiting or already published. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reviewer_id: { type: integer, nullable: true } + note: { type: string, nullable: true, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/return: + post: + tags: [Reviews] + summary: Send a service back for changes + description: Tells whoever asked, with the reason. Requires `services.publish`. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: { type: string, maxLength: 2000 } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/publish: + post: + tags: [Reviews] + summary: Publish a service, now or later + description: | + Approves it: live now, or from `at` when that is in the future. Any + pending review is answered either way. Requires `services.publish`. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time, nullable: true } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/services/{service}/unpublish: + post: + tags: [Reviews] + summary: Take a service off the site + description: Requires `services.publish`. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "200": + description: Where the review stands + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/ReviewState" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/pages/{page}/preview: + post: + tags: [Reviews] + summary: Preview links for a page + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/preview: + post: + tags: [Reviews] + summary: Preview links for a project + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/preview: + post: + tags: [Reviews] + summary: Preview links for a service + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls/{call}/preview: + post: + tags: [Reviews] + summary: Preview links for a call + description: | + One link per language it has an address in, to see it on the real + site before it is published. Each opens this record only and expires + a few hours later. + parameters: + - name: call + in: path + required: true + schema: { type: integer } + responses: + "201": + description: Preview links + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/PreviewLinks" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/health: + get: + tags: [Health] + summary: Site health + description: | + The panel's health screen: every check the caller may see, problems + first — broken images, missing slugs, stale translations, unanswered + submissions… A check behind a permission the caller lacks is left out. + responses: + "200": + description: The findings + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/HealthFinding" } + meta: + type: object + properties: + problems: { type: integer } + checked_at: { type: string, format: date-time } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/translations/pending: + get: + tags: [Health] + summary: What is left to translate + description: | + Pages, projects, services and calls with a language still to write + (`missing`) or left behind by the one it was translated from (`stale`, + with since when). Unpaginated. + parameters: + - name: locale + in: query + description: "Only what this language lacks." + schema: { type: string } + - name: type + in: query + description: "`pages`, `projects`, `services` or `calls`." + schema: { type: string } + - name: published + in: query + description: "Only what is on the site \u2014 open, for calls." + schema: { type: boolean } + responses: + "200": + description: The records + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PendingTranslation" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/batch: + post: + tags: [Batch] + summary: Run several operations + description: | + Up to 50 admin API calls in one request. Each runs as a request of its + own with the caller's credentials — validated, authorised and answered + exactly as if sent separately — so a batch can do nothing its + operations would be refused. Their answers come back in order. + + With `atomic`, it stops at the first failure and undoes everything + before it. What already left the database — a queued job, a + notification — is not undone. + + `path` is an admin path such as `/v1/pages/3`; batches do not nest and + do not reach the import or the token exchange. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [operations] + properties: + atomic: { type: boolean, default: false } + operations: + type: array + maxItems: 50 + items: + type: object + required: [method, path] + properties: + method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] } + path: { type: string, example: /v1/technologies } + body: { type: object, nullable: true, additionalProperties: true } + responses: + "200": + description: Each operation's answer + content: + application/json: + schema: { $ref: "#/components/schemas/BatchOutcome" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/import/{resource}: + post: + tags: [Batch] + summary: Import records + description: | + Rows of one kind of record, as JSON (`rows`) or a CSV `file`, turned + into the create and update calls the API already has — up to 50 per + import, each validated and authorised like any other call. + + A row with an `id`, or whose `match` field (`slug`, `name` or + `full_name`) names an existing record, updates it; any other row + creates one. In a CSV, a column `title.es` fills the Spanish title, a + list is written `1|2|3`, `true`/`false`/`null` are what they say, and + an empty cell is left out rather than erasing what is there. + + All-or-nothing unless `atomic` is false. `dry_run` checks every row and + keeps none. + parameters: + - name: resource + in: path + required: true + schema: + type: string + enum: [pages, categories, projects, members, services, clients, testimonials, talks, technologies, calls] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [rows] + properties: + rows: + type: array + maxItems: 50 + items: { type: object, additionalProperties: true, description: The same body the resource's create endpoint takes. } + match: { type: string, enum: [slug, name, full_name], nullable: true } + atomic: { type: boolean, default: true } + dry_run: { type: boolean, default: false } + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary, description: "CSV with a header row, UTF-8." } + match: { type: string, enum: [slug, name, full_name] } + atomic: { type: boolean, default: true } + dry_run: { type: boolean, default: false } + responses: + "200": + description: Each row's answer + content: + application/json: + schema: { $ref: "#/components/schemas/BatchOutcome" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/webhooks: + get: + tags: [Webhooks] + summary: List webhooks + parameters: + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: Paginated webhooks + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Webhooks] + summary: Create a webhook + description: | + Somewhere to POST to when something happens here. HTTPS to a public + address only — checked now and again at every delivery — and redirects are + not followed. The `secret` signing the deliveries is generated unless sent, + and is in this answer's `secret` once, never again. + + Each delivery is a JSON body `{id, event, created_at, data}` with headers + `X-Webhook-Event`, `X-Webhook-Delivery`, `X-Webhook-Timestamp` and + `X-Webhook-Signature`: `sha256=` and the HMAC-SHA256 of + `{timestamp}.{body}` with the secret. Failed deliveries are retried five + times over about two and a half hours; twenty failures in a row switch the + webhook off. + + Events with a meaning of their own: `application.received` (its + sender's details only with `include_personal_data`), + `content.published`, `content.unpublished`, `review.requested`, + `review.returned`. + + And every change a person makes to a record, as `{resource}.{action}` + — `projects.created`, `pages.updated`, `media.deleted`, + `calls.restored` — for pages, categories, tags, projects, members, + services, clients, testimonials, talks, technologies, calls, + redirects, repositories, media and applications. Its `data` says + what (`type`, `id`, `title`, `url` when it is on the site), who + (`by`), how they came in (`via`: panel, api, token) and, on an + update, `changes` as field → `{old, new}`. What the application does + by itself — a sync, a scheduled publication — is not sent. Changes to + an application carry only how it is handled unless + `include_personal_data`. Users, tokens, webhooks and settings are + never sent. + + Subscribe to an event, to `*`, to all of one kind of record + (`projects.*`) or to one action on any (`*.deleted`). + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWrite" } + responses: + "201": + description: Webhook created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/webhooks/{webhook}: + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + get: + tags: [Webhooks] + summary: Show a webhook + responses: + "200": + description: The webhook + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + patch: + tags: [Webhooks] + summary: Update a webhook + description: "Partial update. `rotate_secret` makes a new secret, in this answer's `secret` once. Switching it back on resets its failure count." + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWrite" } + responses: + "200": + description: Webhook updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Webhook" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Webhooks] + summary: Delete a webhook + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhooks/{webhook}/deliveries: + get: + tags: [Webhooks] + summary: Deliveries of a webhook + description: Newest first. + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + - $ref: "#/components/parameters/PerPage" + - name: status + in: query + description: "`pending`, `succeeded` or `failed`." + schema: { type: string } + responses: + "200": + description: Paginated deliveries + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookDeliveryCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhooks/{webhook}/ping: + post: + tags: [Webhooks] + summary: Send a test delivery + description: A `ping` event, to check the receiving end. + parameters: + - name: webhook + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Ping queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/WebhookDelivery" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/webhook-deliveries/{delivery}/redeliver: + post: + tags: [Webhooks] + summary: Send a delivery again + description: As a new delivery of the same event and payload. + parameters: + - name: delivery + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Delivery queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/WebhookDelivery" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/audit: + get: + tags: [Audit] + summary: The audit log + description: | + Who did what, when, and how they came in — the panel, the API, a + machine token. Every change a person makes to content, the inbox, + redirects, repositories, media, settings, users, tokens and webhooks; + role changes; signing in to the panel. What the application does by + itself (a sync, a scheduled publication) is not here; each record's + own history has it. Secrets are never written down. Kept for a year + by default. Requires `audit.view_any`. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: user_id + in: query + schema: { type: integer } + - name: action + in: query + description: "Exact, e.g. `pages.updated`, or a prefix ending in a dot, e.g. `pages.`." + schema: { type: string } + - name: subject_type + in: query + description: "e.g. `pages`, `users`, `settings`." + schema: { type: string } + - name: subject_id + in: query + schema: { type: string } + - name: via + in: query + description: "`panel`, `api`, `token`, `web` or `console`." + schema: { type: string } + - name: from + in: query + schema: { type: string, format: date-time } + - name: to + in: query + schema: { type: string, format: date-time } + responses: + "200": + description: Paginated entries, newest first + content: + application/json: + schema: { $ref: "#/components/schemas/AuditLogCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/pages/{page}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a page as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: page + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/projects/{project}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a project as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: project + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/services/{service}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a service as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: service + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/calls/{call}/duplicate: + post: + tags: [Reviews] + summary: Duplicate a call as a draft + description: | + A copy to start the next one from: the same content and relations, + unpublished, out of any review, its title marked as a copy and a free + slug in each language. + parameters: + - name: call + in: path + required: true + schema: { type: integer } + responses: + "201": + description: The copy + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + type: { type: string } + id: { type: integer } + title: { type: string } + slug: { type: object, additionalProperties: { type: string } } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}/replies: + get: + tags: [Applications] + summary: Replies given to an application + description: Newest first. + parameters: + - name: application + in: path + required: true + schema: { type: integer } + responses: + "200": + description: The replies + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ApplicationReply" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/applications/{application}/reply: + post: + tags: [Applications] + summary: Reply to an application + description: | + `subject` and `body`, or a `template_id` to fill whichever is left out + — in the language the person wrote in. Sent by this application when + it has a mailer that delivers; otherwise recorded, and the answer + carries a `mailto` link to send it by hand. Moves a new application to + `contacted`. + parameters: + - name: application + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + template_id: { type: integer, nullable: true } + subject: { type: string, maxLength: 255 } + body: { type: string } + responses: + "201": + description: Reply recorded + content: + application/json: + schema: + type: object + properties: + data: + allOf: + - $ref: "#/components/schemas/ApplicationReply" + - type: object + properties: + mailto: { type: string, nullable: true } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/reply-templates: + get: + tags: [Applications] + summary: Reply templates + description: Every template, in every language. Managed in the panel. + responses: + "200": + description: The templates + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ReplyTemplate" } + "403": { $ref: "#/components/responses/Forbidden" } + + /v1/media: + get: + tags: [Media] + summary: List the media library + description: Every image on the media disk, newest first. Requires `media.view_any`. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: for + in: query + description: The kind of record it was uploaded for. + schema: { $ref: "#/components/schemas/MediaFor" } + - name: mime + in: query + description: "e.g. `image/svg+xml`." + schema: { type: string } + - name: search + in: query + description: "Matches the path." + schema: { type: string } + - name: pending + in: query + description: "Only those the queue has not processed yet." + schema: { type: boolean } + - name: failed + in: query + description: "Only those whose processing failed." + schema: { type: boolean } + - name: orphans + in: query + description: "Only those nothing points at \u2014 what `content:prune --media` would delete." + schema: { type: boolean } + responses: + "200": + description: Paginated media + content: + application/json: + schema: { $ref: "#/components/schemas/MediaCollection" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Media] + summary: Upload an image + description: | + Stores an image and answers with the `path` a record points at — send + it as a page's `featured_image`. Two ways in, exactly one per request: + + - **`multipart/form-data`** with the image as `file`. + - **`application/json`** with a `url`, which the API downloads itself. + HTTPS only, from a public address — anything resolving to a private, + reserved or loopback address is refused — and redirects are not + followed: send the image's final address. + + JPEG, PNG, WebP, GIF, AVIF or SVG, decided from the file's bytes rather + than its name or declared type. An SVG is stored sanitized: scripts, + event handlers and references to anything outside the file are + removed. 20 MB at most. + + `for` says which kind of record the image is for — `pages` (the + default), `technologies`, `projects`, `members`, `clients` or + `testimonials` — which decides where it is stored and whose permission + it takes. + + The smaller versions and the placeholder are derived afterwards, on the + queue: `processed` says whether that has happened, and the image serves + either way. Requires permission to create or edit the records named in + `for`. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: The image. + for: { $ref: "#/components/schemas/MediaFor" } + application/json: + schema: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: An `https://` address the API downloads the image from. + example: https://images.example.org/hero.jpg + for: { $ref: "#/components/schemas/MediaFor" } + responses: + "201": + description: Image stored + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/media/{media}: + parameters: + - name: media + in: path + required: true + schema: { type: integer } + get: + tags: [Media] + summary: Show an image + responses: + "200": + description: The image, with whether anything uses it + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + delete: + tags: [Media] + summary: Delete an image + description: | + The file, its derived versions and its record. Only when nothing points + at it — a page, a logo, an image inside a text — since deleting one in + use would put a broken image on the public site. Requires + `media.delete`. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { description: The image is in use. } + + /v1/media/{media}/reprocess: + post: + tags: [Media] + summary: Process an image again + description: Queues deriving the smaller versions and the placeholder again. + parameters: + - name: media + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Processing queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/users: get: tags: [Users] @@ -1320,9 +4660,9 @@ components: type: http scheme: bearer description: | - An access token issued by https://oauth.pireon.dev. Validated on each - request against the identity server's `GET /api/user`, with the result - cached briefly. + An access token issued by https://oauth.pireon.dev, validated on each + request against the identity server's `GET /api/user` with the result + cached briefly — or a machine token (`pmt_…`) from `POST /v1/tokens`. ingestToken: type: apiKey @@ -1701,6 +5041,15 @@ components: repositories: type: array items: { $ref: "#/components/schemas/PublicRepository" } + taskstream: + type: object + nullable: true + description: "Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue." + properties: + key: { type: string, example: WEB } + url: { type: string, format: uri } + web_url: { type: string, format: uri } + showcase_url: { type: string, format: uri } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true, example: /proyectos/mi-web } @@ -1768,6 +5117,16 @@ components: alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } + PublicSearchResult: + type: object + properties: + type: { type: string, enum: [page, project, service, call, member, technology] } + id: { type: integer } + title: { type: string } + excerpt: { type: string, description: Plain text around the first match. } + path: { type: string, nullable: true, example: /proyectos/mi-proyecto } + score: { type: number, description: Higher is a better match. Only comparable within one response. } + PublicTechnology: type: object properties: @@ -2000,6 +5359,26 @@ components: cache downstream at the same moment. updated_at: { type: string, format: date-time } category: { $ref: "#/components/schemas/PublicCategory" } + tags: + type: array + description: The ones named in this language, to link to the blog filtered by each. + items: + type: object + properties: + name: { type: string } + slug: { type: string } + byline: + type: object + nullable: true + description: The member who signs it, when they have a public profile. + properties: + name: { type: string } + slug: { type: string } + headline: { type: string, nullable: true } + avatar: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + path: { type: string } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } @@ -2076,6 +5455,8 @@ components: deleted_at: { type: string, format: date-time, nullable: true } category: { $ref: "#/components/schemas/Category" } author: { $ref: "#/components/schemas/User" } + byline_member_id: { type: integer, nullable: true, description: The member who signs it on the site. } + tag_ids: { type: array, items: { type: integer } } featured_image_alt: type: string nullable: true @@ -2104,6 +5485,8 @@ components: meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } category_id: { type: integer, nullable: true } + byline_member_id: { type: integer, nullable: true, description: The member who signs it on the site. } + tag_ids: { type: array, items: { type: integer }, description: Replaced whole when sent. } status: { type: string, enum: [draft, review, published, archived] } featured_image: type: string @@ -2123,6 +5506,969 @@ 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 } + taskstream_key: { type: string, nullable: true, description: "The project's key in TaskStream, when it has a board there." } + taskstream_url: { type: string, nullable: true, format: uri, readOnly: true, description: Where its TaskStream board opens. } + 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 } + taskstream: + type: boolean + description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`. + taskstream_key: + type: string + nullable: true + pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" + description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out. + 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" } + + Client: + type: object + properties: + id: { type: integer } + locale: { type: string } + name: { type: string } + slug: { type: string } + sector: { type: string, nullable: true } + logo: { type: string, nullable: true, description: The stored path. } + logo_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + url: { type: string, format: uri, nullable: true } + is_public: { type: boolean } + position: { type: integer } + projects_count: { 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`." } + + ClientWrite: + type: object + required: [name, slug] + properties: + name: { type: string, maxLength: 255, description: A proper noun; not translated. } + slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" } + sector: { $ref: "#/components/schemas/TranslatedField" } + logo: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=clients`. } + url: { type: string, format: uri, nullable: true } + is_public: { type: boolean } + position: { type: integer, minimum: 0 } + + ClientCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Client" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Testimonial: + type: object + properties: + id: { type: integer } + locale: { type: string } + quote: { type: string, nullable: true } + author_role: { type: string, nullable: true } + original_locale: { type: string, description: The language the person spoke in. } + author_name: { type: string } + avatar: { type: string, nullable: true, description: The stored path. } + avatar_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + client_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + service_id: { type: integer, nullable: true } + is_public: { type: boolean } + position: { type: integer } + given_at: { type: string, format: date, 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`." } + + TestimonialWrite: + type: object + required: [quote, original_locale, author_name] + properties: + quote: { $ref: "#/components/schemas/TranslatedField" } + original_locale: { type: string, description: A supported language code. } + author_name: { type: string, maxLength: 255 } + author_role: { $ref: "#/components/schemas/TranslatedField" } + avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=testimonials`. } + client_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + service_id: { type: integer, nullable: true } + is_public: { type: boolean } + position: { type: integer, minimum: 0 } + given_at: { type: string, format: date, nullable: true } + + TestimonialCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Testimonial" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Talk: + type: object + properties: + id: { type: integer } + locale: { type: string } + title: { type: string } + language: { type: string } + summary: { type: string, nullable: true } + event: { type: string } + city: { type: string, nullable: true } + held_at: { type: string, format: date, nullable: true } + video_url: { type: string, format: uri, nullable: true } + slides_url: { type: string, format: uri, nullable: true } + member_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + is_public: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + TalkWrite: + type: object + required: [title, language, event] + properties: + title: { type: string, maxLength: 255, description: Not translated. } + language: { type: string, description: A supported language code. } + summary: { $ref: "#/components/schemas/TranslatedField" } + event: { type: string, maxLength: 255 } + city: { type: string, nullable: true, maxLength: 120 } + held_at: { type: string, format: date, nullable: true } + video_url: { type: string, format: uri, nullable: true } + slides_url: { type: string, format: uri, nullable: true } + member_id: { type: integer, nullable: true } + project_id: { type: integer, nullable: true } + is_public: { type: boolean } + + TalkCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Talk" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Call: + type: object + properties: + id: { type: integer } + locale: { type: string } + title: { type: string, nullable: true } + slug: { type: string, nullable: true } + description: { type: string, nullable: true } + what_you_get: { type: string, nullable: true } + status: { type: string, enum: [open, filled, closed] } + hours_per_week: { type: integer, nullable: true } + duration_weeks: { type: integer, nullable: true } + seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } + opens_at: { type: string, format: date, nullable: true } + closes_at: { type: string, format: date, nullable: true } + project_id: { type: integer, nullable: true } + technology_ids: { type: array, items: { type: integer } } + applications_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } + + CallWrite: + type: object + required: [title, slug] + properties: + title: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + description: { $ref: "#/components/schemas/TranslatedField" } + what_you_get: { $ref: "#/components/schemas/TranslatedField" } + status: { type: string, enum: [open, filled, closed], default: open } + hours_per_week: { type: integer, nullable: true, minimum: 1, maximum: 40 } + duration_weeks: { type: integer, nullable: true, minimum: 1 } + seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } + opens_at: { type: string, format: date, nullable: true } + closes_at: { type: string, format: date, nullable: true } + project_id: { type: integer, nullable: true } + technology_ids: { type: array, items: { type: integer } } + + CallCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Call" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Application: + type: object + properties: + id: { type: integer } + kind: { type: string, enum: [contribute, request] } + status: { type: string, enum: [new, contacted, accepted, declined, spam] } + call_id: { type: integer, nullable: true } + subject: { type: string, description: "The call's title, or a general enquiry." } + name: { type: string } + email: { type: string, format: email } + organisation: { type: string, nullable: true } + message: { type: string } + hours_per_week: { type: integer, nullable: true } + links: { type: object, additionalProperties: { type: string }, description: "The links the sender gave, keyed as they were sent." } + locale: { type: string, description: The language it was written in. } + notes: { type: string, nullable: true, description: Private to the collective. } + handled_by: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + handled_at: { type: string, format: date-time, 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 } + + ApplicationWrite: + type: object + properties: + status: { type: string, enum: [new, contacted, accepted, declined, spam] } + notes: { type: string, nullable: true, maxLength: 10000 } + + ApplicationCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Application" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Redirect: + type: object + properties: + id: { type: integer } + from: { type: string } + to: { type: string } + status: { type: integer, enum: [301, 302] } + locale: { type: string, nullable: true, description: Null applies in every language. } + is_enabled: { type: boolean } + automatic: { type: boolean, description: Left behind by a slug rename. } + hits: { type: integer } + last_used_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + RedirectWrite: + type: object + required: [from, to] + properties: + from: { type: string, maxLength: 500 } + to: { type: string, maxLength: 500, description: Different from `from`. } + status: { type: integer, enum: [301, 302], default: 301 } + locale: { type: string, nullable: true } + is_enabled: { type: boolean, default: true } + + RedirectCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Redirect" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + MissingUrl: + type: object + properties: + id: { type: integer } + path: { type: string } + locale: { type: string } + referrer: { type: string, nullable: true } + internal_referrer: { type: boolean, description: "A page of our own links here — more urgent than someone else's stale link." } + status: { type: string, enum: [pending, resolved, ignored] } + hits: { type: integer } + first_seen_at: { type: string, format: date-time, nullable: true } + last_seen_at: { type: string, format: date-time, nullable: true } + resolved_at: { type: string, format: date-time, nullable: true } + + MissingUrlWrite: + type: object + required: [status] + properties: + status: { type: string, enum: [pending, ignored] } + + MissingUrlCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/MissingUrl" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Repository: + type: object + properties: + id: { type: integer } + provider: { type: string, enum: [gitea, github, gitlab] } + full_name: { type: string, description: "`owner/name`." } + project_id: { type: integer, nullable: true } + is_primary: { type: boolean } + show_publicly: { type: boolean } + html_url: { type: string, nullable: true } + description: { type: string, nullable: true } + homepage: { type: string, nullable: true } + default_branch: { type: string, nullable: true } + primary_language: { type: string, nullable: true } + license: { type: string, nullable: true } + topics: { type: array, items: { type: string } } + stars: { type: integer } + forks: { type: integer } + open_issues_count: { type: integer } + is_archived: { type: boolean } + pushed_at: { type: string, format: date-time, nullable: true } + last_synced_at: { type: string, format: date-time, nullable: true } + sync_error: { type: string, nullable: true } + sync_failures: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + RepositoryWrite: + type: object + required: [provider, full_name] + properties: + provider: { type: string, enum: [gitea, github, gitlab] } + full_name: { type: string, maxLength: 255, description: "`owner/name`, unique per provider." } + project_id: { type: integer, nullable: true } + is_primary: { type: boolean } + show_publicly: { type: boolean } + + RepositoryCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Repository" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Setting: + type: object + properties: + key: { type: string } + value: { nullable: true, description: Any JSON value. } + updated_at: { type: string, format: date-time, nullable: true } + + Maintenance: + type: object + properties: + enabled: { type: boolean, description: False once `until` has passed. } + message: { type: object, additionalProperties: { type: string }, description: Language → text shown to visitors. } + until: { type: string, format: date-time, nullable: true } + started_at: { type: string, format: date-time, nullable: true } + + Revision: + type: object + properties: + id: { type: integer } + event: { type: string, enum: [created, updated, published, scheduled, unpublished, deleted, restored, erased] } + author: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string } + changes: + type: object + description: "Field → `{old, new}`. Translatable fields carry every language." + additionalProperties: + type: object + properties: + old: { nullable: true } + new: { nullable: true } + created_at: { type: string, format: date-time } + + RevisionCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Revision" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + ReviewComment: + type: object + properties: + id: { type: integer } + event: { type: string, enum: [requested, returned, published, commented] } + body: { type: string, nullable: true } + author: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + created_at: { type: string, format: date-time } + + ReviewCommentCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ReviewComment" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + ReviewState: + type: object + properties: + type: { type: string, enum: [pages, projects, services] } + id: { type: integer } + title: { type: string, nullable: true } + published: { type: boolean, description: On the site right now. } + published_at: { type: string, format: date-time, nullable: true, description: In the future when scheduled. } + awaiting_review: { type: boolean } + requested_at: { type: string, format: date-time, nullable: true } + requested_by: { $ref: "#/components/schemas/UserRef" } + reviewer: { $ref: "#/components/schemas/UserRef" } + + UserRef: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + + PreviewLinks: + type: object + properties: + links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. } + expires_at: { type: string, format: date-time } + + HealthFinding: + type: object + properties: + key: { type: string, example: missing-translations } + severity: { type: string, enum: [ok, warning, error] } + count: { type: integer } + title: { type: string } + description: { type: string } + samples: { type: array, items: { type: string }, description: Up to three examples. } + panel_url: { type: string, nullable: true, description: Where to fix it in the panel. } + + PendingTranslation: + type: object + properties: + type: { type: string, enum: [pages, projects, services, calls] } + id: { type: integer } + title: { type: string } + missing: { type: array, items: { type: string }, description: Languages with nothing written. } + stale: { type: object, additionalProperties: { type: string, format: date-time }, description: Language → since when it has been behind. } + + BatchOutcome: + type: object + properties: + data: + type: array + items: + type: object + properties: + index: { type: integer } + method: { type: string } + path: { type: string } + status: { type: integer, description: The HTTP status the operation answered with. } + body: { nullable: true, description: What the operation answered. } + meta: + type: object + properties: + committed: { type: boolean, description: False when an atomic run or a dry run undid everything. } + succeeded: { type: integer } + failed: { type: integer } + dry_run: { type: boolean, nullable: true } + + ApiToken: + type: object + properties: + id: { type: integer } + name: { type: string } + prefix: { type: string, description: "The first characters, to tell tokens apart." } + abilities: { type: array, items: { type: string } } + owner: + type: object + properties: + id: { type: integer } + name: { type: string, nullable: true } + status: { type: string, enum: [active, expired, revoked] } + expires_at: { type: string, format: date-time } + last_used_at: { type: string, format: date-time, nullable: true } + revoked_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + + ApiTokenCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ApiToken" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Webhook: + type: object + properties: + id: { type: integer } + name: { type: string } + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + include_personal_data: { type: boolean } + is_active: { type: boolean } + consecutive_failures: { type: integer } + last_delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + WebhookWrite: + type: object + required: [name, url, events] + properties: + name: { type: string, maxLength: 120 } + url: { type: string, format: uri, description: "HTTPS, to a public address." } + events: + type: array + items: + type: string + description: "An event, `*`, `{resource}.*` or `*.{action}`." + example: projects.* + secret: { type: string, minLength: 16, description: On creation only; generated when left out. } + rotate_secret: { type: boolean, description: On update only. } + include_personal_data: { type: boolean, default: false } + is_active: { type: boolean } + + WebhookCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Webhook" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + WebhookDelivery: + type: object + properties: + id: { type: integer } + uuid: { type: string, description: "Sent as `X-Webhook-Delivery`." } + event: { type: string } + status: { type: string, enum: [pending, succeeded, failed] } + attempts: { type: integer } + response_status: { type: integer, nullable: true } + error: { type: string, nullable: true } + payload: { type: object, additionalProperties: true } + delivered_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time } + + WebhookDeliveryCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/WebhookDelivery" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + AuditLog: + type: object + properties: + id: { type: integer } + action: { type: string, example: pages.updated } + user: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + via: { type: string, enum: [panel, api, token, web, console] } + api_token_id: { type: integer, nullable: true, description: The machine token used. } + ip: { type: string, nullable: true } + subject: + type: object + nullable: true + properties: + type: { type: string } + id: { type: string } + label: { type: string, nullable: true } + changes: + type: object + nullable: true + additionalProperties: true + description: "Field → `{old, new}` on an update; the values on a creation. Long values are cut short." + created_at: { type: string, format: date-time } + + AuditLogCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/AuditLog" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + ApplicationReply: + type: object + properties: + id: { type: integer } + subject: { type: string } + body: { type: string } + channel: { type: string, enum: [mail, mailto], description: "`mail` when sent from here; `mailto` when handed to the replier's mail program." } + author: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + created_at: { type: string, format: date-time } + + ReplyTemplate: + type: object + properties: + id: { type: integer } + name: { type: string } + kind: { type: string, nullable: true, enum: [contribute, request] } + subject: { type: object, additionalProperties: { type: string }, description: "Language → text. `{name}`, `{subject}` and `{sender}` are filled in." } + body: { type: object, additionalProperties: { type: string } } + + Tag: + type: object + properties: + id: { type: integer } + name: { type: object, additionalProperties: { type: string }, description: Language → name. } + slug: { type: object, additionalProperties: { type: string } } + pages_count: { type: integer } + + TagWrite: + type: object + required: [name, slug] + properties: + name: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + + MediaFor: + type: string + enum: [pages, technologies, projects, members, clients, testimonials] + default: pages + description: The kind of record the image is for. + + Technology: + type: object + properties: + id: { type: integer } + locale: { type: string, description: The language the prose below was resolved into. } + name: { type: string, description: A proper noun — the same in every language. } + slug: { type: string } + description: { type: string, nullable: true } + radar_note: { type: string, nullable: true, description: Why it sits in its ring. } + category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } + radar_ring: + type: string + enum: [adopt, trial, assess, hold] + nullable: true + description: Null keeps it off the radar; projects can still use it. + color: { type: string, nullable: true, example: "#DD0031" } + logo: + type: string + nullable: true + description: The stored path — what `POST /v1/media` answered — and what a client PATCHes back. + logo_media: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + readOnly: true + website: { type: string, nullable: true, format: uri } + position: { type: integer } + projects_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + translations: + type: object + nullable: true + description: Every language of `description` and `radar_note`, with `?with=translations`. + + TechnologyWrite: + type: object + required: [name, slug] + properties: + name: { type: string, maxLength: 120 } + slug: { type: string, maxLength: 120, pattern: "^[A-Za-z0-9_-]+$" } + description: { $ref: "#/components/schemas/TranslatedField" } + radar_note: { $ref: "#/components/schemas/TranslatedField" } + category: + type: string + enum: [language, framework, datastore, infrastructure, tool, practice] + default: tool + radar_ring: { type: string, enum: [adopt, trial, assess, hold], nullable: true } + color: { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" } + logo: + type: string + nullable: true + description: A path from `POST /v1/media`, uploaded with `for=technologies`. + website: { type: string, format: uri, nullable: true, maxLength: 255 } + position: { type: integer, minimum: 0 } + + TechnologyCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Technology" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + + Media: + type: object + properties: + id: { type: integer } + path: + type: string + description: What a record stores, e.g. as a page's `featured_image`. + example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg + url: { type: string, format: uri, description: Absolute. } + mime: { type: string, nullable: true, example: image/jpeg } + size: { type: integer, nullable: true, description: Bytes. } + width: { type: integer, nullable: true } + height: { type: integer, nullable: true } + processed: + type: boolean + description: | + Whether the queue has derived the smaller versions and the + placeholder yet. The image serves either way. + error: { type: string, nullable: true, description: "Why processing failed, when it did." } + referenced: + type: boolean + nullable: true + description: Whether anything points at it. Sent by the library only, not on upload. + created_at: { type: string, format: date-time } + + MediaCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Media" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } + Category: type: object properties: diff --git a/landing/src/app/core/api.types.ts b/landing/src/app/core/api.types.ts index 749e830..cdaed06 100644 --- a/landing/src/app/core/api.types.ts +++ b/landing/src/app/core/api.types.ts @@ -241,6 +241,10 @@ export interface PublicProject { lead: PublicMember; calls: PublicCall[]; repositories: PublicRepository[]; + /** + * Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue. + */ + taskstream?: { key: string; url: string; web_url: string; showcase_url: string }; alternates: Alternates; path?: string; } diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index e505f3b..dbbbfe9 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -107,6 +107,16 @@ export interface Copy { archived: string; openCalls: string; technologies: string; + roadmap: string; + roadmapLead: string; + releases: string; + released: string; + planned: string; + sprint: string; + goodFirst: string; + goodFirstLead: string; + board: string; + done: string; }; member: { projects: string; @@ -297,6 +307,16 @@ const es: Copy = { archived: 'Archivado', openCalls: 'Buscamos ayuda', technologies: 'Tecnologías', + roadmap: 'Hoja de ruta', + roadmapLead: 'Lo que viene, según nuestro tablero.', + releases: 'Versiones', + released: 'Publicada', + planned: 'Prevista', + sprint: 'Ahora mismo', + goodFirst: 'Para empezar a colaborar', + goodFirstLead: 'Tareas pensadas para quien llega nuevo.', + board: 'Ver el tablero', + done: 'Hecho', }, member: { projects: 'Proyectos', @@ -506,6 +526,16 @@ const en: Copy = { archived: 'Archived', openCalls: 'Looking for help', technologies: 'Technologies', + roadmap: 'Roadmap', + roadmapLead: 'What is coming, from our board.', + releases: 'Releases', + released: 'Released', + planned: 'Planned', + sprint: 'Right now', + goodFirst: 'Good first issues', + goodFirstLead: 'Tasks meant for newcomers.', + board: 'See the board', + done: 'Done', }, member: { projects: 'Projects', @@ -715,6 +745,16 @@ const ca: Copy = { archived: 'Arxivat', openCalls: 'Busquem ajuda', technologies: 'Tecnologies', + roadmap: 'Full de ruta', + roadmapLead: 'El que ve, segons el nostre tauler.', + releases: 'Versions', + released: 'Publicada', + planned: 'Prevista', + sprint: 'Ara mateix', + goodFirst: 'Per començar a col·laborar', + goodFirstLead: 'Tasques pensades per a qui arriba de nou.', + board: 'Veure el tauler', + done: 'Fet', }, member: { projects: 'Projectes', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index 77e7f40..dcc7750 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -25,11 +25,16 @@ import type { import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog'; import { copyFor } from './copy'; import { listingPath, resolveRoute, type RouteMatch } from './routes'; +import { + fetchShowcase, + hasContent, + type ProjectBoard, +} from './taskstream'; /** The record, tagged with its type so the template can switch on it. */ export type RoutedRecord = | { type: 'page'; record: PublicPage } - | { type: 'project'; record: PublicProject } + | { type: 'project'; record: PublicProject; board: ProjectBoard | null } | { type: 'member'; record: PublicMember } | { type: 'call'; record: PublicCall } | { type: 'service'; record: PublicService } @@ -82,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise { } case 'project': { const { data } = await pireonApi.project(locale, slug); - return data ? { type: 'project', record: data } : null; + return data + ? { type: 'project', record: data, board: await boardOf(data) } + : null; } case 'member': { const { data } = await pireonApi.member(locale, slug); @@ -166,3 +173,13 @@ export function pageNavLinks( ] : featured; } + +/** The project's TaskStream board, when it has one with something to show. */ +async function boardOf(project: PublicProject): Promise { + const board = project.taskstream; + if (!board?.showcase_url) return null; + const showcase = await fetchShowcase(board.showcase_url); + return showcase && hasContent(showcase) + ? { url: board.url, webUrl: board.web_url, showcase } + : null; +} diff --git a/landing/src/app/core/taskstream.spec.ts b/landing/src/app/core/taskstream.spec.ts new file mode 100644 index 0000000..652a48e --- /dev/null +++ b/landing/src/app/core/taskstream.spec.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { boardLink, fetchShowcase, hasContent } from './taskstream'; + +const showcase = { + project: { id: 'p', key: 'WEB', name: 'Web', description: null }, + activeSprint: null, + roadmap: [], + releases: [], + goodFirst: [ + { key: 'WEB-9', title: 'Typo', type: 'TASK', path: '/p/WEB/issues/WEB-9' }, + ], +}; + +describe('TaskStream showcase', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reads a showcase, and goes without it when TaskStream fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(showcase))), + ); + expect(await fetchShowcase('https://api.ts/showcase')).toEqual(showcase); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('nope', { status: 404 })), + ); + expect(await fetchShowcase('https://api.ts/showcase')).toBeNull(); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('down'); + }), + ); + expect(await fetchShowcase('https://api.ts/showcase')).toBeNull(); + }); + + it('builds links into the board and knows an empty one', () => { + expect( + boardLink('https://taskstream.pireon.dev/', '/p/WEB/issues/WEB-9'), + ).toBe('https://taskstream.pireon.dev/p/WEB/issues/WEB-9'); + expect(hasContent(showcase)).toBe(true); + expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false); + }); +}); diff --git a/landing/src/app/core/taskstream.ts b/landing/src/app/core/taskstream.ts new file mode 100644 index 0000000..40c19f5 --- /dev/null +++ b/landing/src/app/core/taskstream.ts @@ -0,0 +1,85 @@ +/** + * A project's board in TaskStream, the collective's issue tracker: its public + * showcase (roadmap, releases, the sprint under way and tickets for + * newcomers). Fetched on the server; a slow or absent TaskStream only means + * the project page goes without those sections. + */ + +export interface TaskStreamShowcase { + project: { + id: string; + key: string; + name: string; + description: string | null; + }; + activeSprint: { + name: string; + goal: string | null; + startDate: string; + endDate: string; + percent: number; + } | null; + roadmap: { + key: string; + title: string; + startDate: string | null; + dueDate: string | null; + percent: number; + done: boolean; + path: string; + }[]; + releases: { + name: string; + status: 'PLANNED' | 'RELEASED' | 'ARCHIVED'; + targetDate: string | null; + releasedAt: string | null; + percent: number; + notes: string | null; + }[]; + goodFirst: { key: string; title: string; type: string; path: string }[]; +} + +/** What the page renders: the showcase plus where its links go. */ +export interface ProjectBoard { + url: string; + webUrl: string; + showcase: TaskStreamShowcase; +} + +export async function fetchShowcase( + showcaseUrl: string, + timeout = 4000, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(showcaseUrl, { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + if (!response.ok) return null; + const body = (await response.json()) as Partial; + return Array.isArray(body.roadmap) && Array.isArray(body.goodFirst) + ? (body as TaskStreamShowcase) + : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** Something worth showing: a board with nothing on it says nothing. */ +export function hasContent(showcase: TaskStreamShowcase): boolean { + return ( + showcase.roadmap.length > 0 || + showcase.releases.length > 0 || + showcase.goodFirst.length > 0 || + showcase.activeSprint !== null + ); +} + +/** A showcase path as an address in TaskStream's web app. */ +export function boardLink(webUrl: string, path: string): string { + return `${webUrl.replace(/\/$/, '')}${path}`; +} diff --git a/landing/src/app/ui/project-detail.scss b/landing/src/app/ui/project-detail.scss index fa78f1e..4f62c6b 100644 --- a/landing/src/app/ui/project-detail.scss +++ b/landing/src/app/ui/project-detail.scss @@ -87,3 +87,50 @@ margin: 0; } } + +.project-roadmap, +.project-releases, +.project-first { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.project-roadmap__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.25rem 1rem; + align-items: baseline; + + &.is-done a { + text-decoration: line-through; + } +} + +.project-roadmap__bar { + grid-column: 1 / -1; + height: 0.375rem; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, currentColor 12%, transparent); + + span { + display: block; + height: 100%; + background: var(--pi-color-primary, currentColor); + } +} + +.project-releases li { + display: grid; + gap: 0.25rem; +} + +.project-releases__notes { + margin: 0; + white-space: pre-wrap; + font: inherit; + font-size: 0.9rem; +} diff --git a/landing/src/app/ui/project-detail.ts b/landing/src/app/ui/project-detail.ts index 5199391..6a64c82 100644 --- a/landing/src/app/ui/project-detail.ts +++ b/landing/src/app/ui/project-detail.ts @@ -16,6 +16,7 @@ import { import type { Locale } from '../core/api'; import type { PublicProject } from '../core/api.types'; import type { Copy } from '../core/copy'; +import { boardLink, type ProjectBoard } from '../core/taskstream'; import { ApiImage } from './api-image'; import { ContactCta } from './contact-cta'; import { PROJECT_STATUS_TONES } from './project-card'; @@ -143,6 +144,61 @@ import { LinkButton } from './link-button'; } + @if (board(); as b) { + @if (b.showcase.roadmap.length > 0) { +
+

{{ copy().project.roadmap }}

+

{{ copy().project.roadmapLead }}

+
    + @for (epic of b.showcase.roadmap; track epic.key) { +
  1. + {{ + epic.title + }} + {{ + dates(epic.startDate, epic.dueDate) + }} + +
  2. + } +
+
+ } + @if (b.showcase.releases.length > 0) { +
+

{{ copy().project.releases }}

+
    + @for (release of b.showcase.releases; track release.name) { +
  • + {{ release.name }} + + {{ + release.status === 'RELEASED' + ? copy().project.released + : copy().project.planned + }} + @if (release.releasedAt ?? release.targetDate; as day) { + · {{ dates(day, null) }} + } + @if (release.status !== 'RELEASED') { + · {{ release.percent }}% + } + + @if (release.notes) { +
    {{ release.notes }}
    + } +
  • + } +
+
+ } + } + @if ((project().gallery ?? []).length > 0) {

{{ copy().project.gallery }}

@@ -202,6 +258,36 @@ import { LinkButton } from './link-button'; + @if (board(); as b) { +
+ @if (b.showcase.activeSprint; as sprint) { +

{{ copy().project.sprint }}

+

+ {{ sprint.name }} · {{ sprint.percent }}% + @if (sprint.goal) { +
{{ sprint.goal }} + } +

+ } + @if (b.showcase.goodFirst.length > 0) { +

{{ copy().project.goodFirst }}

+

{{ copy().project.goodFirstLead }}

+ + } + {{ + copy().project.board + }} → +
+ } + @if ((project().calls ?? []).length > 0) {