feat: attachments on tickets and comments

Files are uploaded over REST, stored on disk (a volume in Docker) and
recorded on the ticket as events; the bytes are written first, so no
attachment ever points at a missing file. Only images, video, audio, PDF
and plain text are shown inline, everything with nosniff and a sandbox CSP.
The web shows thumbnails, uploads by drag and drop or by pasting a
screenshot, and attaches files to new comments.

Also adds `nx run api:contracts`, which writes schema.gql and
openapi.json without starting the API or any database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 08:35:33 +02:00
parent 80d9bee6e0
commit 0b65c1c912
72 changed files with 6766 additions and 4297 deletions

View File

@@ -3237,6 +3237,308 @@
]
}
},
"/api/issues/{issue}/attachments": {
"post": {
"description": "Multipart with the file as `file` and, optionally, `commentId` to place it under a comment. Up to ATTACHMENTS_MAX_MB megabytes (25 by default) and 50 files per ticket.",
"operationId": "AttachmentsController_attach",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AttachFileDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Attach a file to a ticket (members)",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/attachments/{attachmentId}": {
"get": {
"operationId": "AttachmentsController_download",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
},
{
"name": "attachmentId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The file"
},
"400": {
"description": "Invalid input",
"content": {
"application/octet-stream": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/octet-stream": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/octet-stream": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/octet-stream": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
},
{}
],
"summary": "Download one of a ticket's files",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "AttachmentsController_remove",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
},
{
"name": "attachmentId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Remove a file (whoever uploaded it, or an owner/admin)",
"tags": [
"Issues"
]
}
},
"/api/comments/{commentId}": {
"patch": {
"operationId": "CommentsController_edit",
@@ -5309,6 +5611,50 @@
"issueKey"
]
},
"AttachmentDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"fileName": {
"type": "string",
"example": "captura-login.png"
},
"mimeType": {
"type": "string",
"example": "image/png"
},
"size": {
"type": "number",
"description": "Bytes",
"example": 48213
},
"uploadedBy": {
"$ref": "#/components/schemas/UserRefDto"
},
"uploadedAt": {
"type": "string",
"format": "date-time"
},
"commentId": {
"type": "string",
"nullable": true,
"format": "uuid",
"description": "The comment it was attached to, if any"
}
},
"required": [
"id",
"fileName",
"mimeType",
"size",
"uploadedBy",
"uploadedAt",
"commentId"
]
},
"IssueDto": {
"type": "object",
"properties": {
@@ -5423,6 +5769,12 @@
"$ref": "#/components/schemas/IssueLinkDto"
}
},
"attachments": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AttachmentDto"
}
},
"commentCount": {
"type": "number"
},
@@ -5460,6 +5812,7 @@
"storyPoints",
"dueDate",
"links",
"attachments",
"commentCount",
"createdAt",
"updatedAt",
@@ -6156,6 +6509,24 @@
"body"
]
},
"AttachFileDto": {
"type": "object",
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "The file"
},
"commentId": {
"type": "string",
"format": "uuid",
"description": "One of the ticket's comments, to show the file under it"
}
},
"required": [
"file"
]
},
"ReleaseStatus": {
"type": "string",
"enum": [