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": [

View File

@@ -3,15 +3,10 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"tags": [
"scope:api",
"type:app"
],
"tags": ["scope:api", "type:app"],
"targets": {
"prune-lockfile": {
"dependsOn": [
"build"
],
"dependsOn": ["build"],
"cache": true,
"executor": "@nx/js:prune-lockfile",
"outputs": [
@@ -23,32 +18,23 @@
}
},
"copy-workspace-modules": {
"dependsOn": [
"build"
],
"dependsOn": ["build"],
"cache": true,
"outputs": [
"{workspaceRoot}/dist/apps/api/workspace_modules"
],
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
"executor": "@nx/js:copy-workspace-modules",
"options": {
"buildTarget": "build"
}
},
"prune": {
"dependsOn": [
"prune-lockfile",
"copy-workspace-modules"
],
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
"executor": "nx:noop"
},
"serve": {
"continuous": true,
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"dependsOn": [
"build"
],
"dependsOn": ["build"],
"options": {
"buildTarget": "api:build",
"runBuildTargetDependencies": false
@@ -73,6 +59,13 @@
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
},
"cache": false
},
"contracts": {
"executor": "nx:run-commands",
"options": {
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
},
"cache": false
}
}
}

View File

@@ -25,6 +25,22 @@ input AreaOptionInput {
name: String!
}
"""
A file on a ticket. Download it from the REST API: GET /api/issues/{issueId}/attachments/{id}
"""
type Attachment {
"""The comment it was attached to, if any"""
commentId: ID
fileName: String!
id: ID!
mimeType: String!
"""Bytes"""
size: Int!
uploadedAt: String!
uploadedBy: UserRef!
}
type BoardColumn {
issues: [Issue!]!
status: String!
@@ -168,6 +184,7 @@ type Issue {
"""An area id"""
area: String
assignee: UserRef
attachments: [Attachment!]!
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
createdAt: String!
@@ -329,6 +346,11 @@ type Mutation {
Ship the release. Fails while it has unfinished tickets unless force = true.
"""
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
"""
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
"""
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""Put a ticket in a release (null removes it)"""

View File

@@ -0,0 +1,117 @@
/**
* Writes apps/api/schema.gql and apps/api/openapi.json without starting the
* API or touching a database: `npx nx run api:contracts`.
*
* The running API writes the same files on start in development; this is for
* when no database is at hand — a CI job, a laptop without Docker running.
*/
import 'reflect-metadata';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { InjectionToken, Module, Provider, Type } from '@nestjs/common';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { NestFactory } from '@nestjs/core';
import {
GRAPHQL_SDL_FILE_HEADER,
GraphQLSchemaBuilderModule,
GraphQLSchemaFactory,
} from '@nestjs/graphql';
import { IdentityPresentersModule } from '@task-stream/api/identity/presenters';
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
import { lexicographicSortSchema, printSchema } from 'graphql';
import { AppModule } from '../src/app/app.module';
import { buildOpenApiDocument } from '../src/app/openapi';
/** The GraphQL resolvers a presenters module declares. */
function resolversOf(module: Type): Type[] {
const providers: unknown[] =
Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? [];
return providers.filter(
(provider): provider is Type =>
typeof provider === 'function' &&
// `@Resolver()` without a type (subscriptions) sets no type, only a name.
Reflect.getMetadataKeys(provider).some((key) =>
String(key).startsWith('graphql:resolver'),
),
);
}
async function graphqlSchema(): Promise<string> {
const app = await NestFactory.create(GraphQLSchemaBuilderModule, {
logger: false,
});
await app.init();
const schema = await app
.get(GraphQLSchemaFactory)
.create([
...resolversOf(IdentityPresentersModule),
...resolversOf(TrackerPresentersModule),
]);
await app.close();
// Same output as GraphQLModule's autoSchemaFile with sortSchema.
return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema));
}
/** The REST controllers a module declares. */
function controllersOf(module: Type): Type[] {
return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? [];
}
/**
* Empty stand-ins for whatever the controllers' constructors ask for: Swagger
* reads the controllers' metadata and never calls them.
*/
function standIns(controllers: Type[]): Provider[] {
const tokens = new Set<unknown>();
for (const controller of controllers) {
const types: unknown[] =
Reflect.getMetadata('design:paramtypes', controller) ?? [];
const injected: { index: number; param: unknown }[] =
Reflect.getMetadata('self:paramtypes', controller) ?? [];
types.forEach((type, index) =>
tokens.add(injected.find((i) => i.index === index)?.param ?? type),
);
}
return [...tokens].map((token) => ({
provide: token as InjectionToken,
useValue: {},
}));
}
async function openApi(): Promise<string> {
// Only the controllers, with stand-ins for their dependencies: nothing
// connects to Postgres, Mongo, Redis or Pireon.
const controllers = [
...controllersOf(AppModule),
...controllersOf(IdentityPresentersModule),
...controllersOf(TrackerPresentersModule),
];
@Module({ controllers, providers: standIns(controllers) })
class ContractsModule {}
const app = await NestFactory.create(ContractsModule, { logger: false });
app.setGlobalPrefix('api');
const document = buildOpenApiDocument(app, {
pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev',
scopes: (process.env['PIREON_SCOPES'] || 'users:read')
.split(/[\s,]+/)
.filter(Boolean),
passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false',
});
return JSON.stringify(document, null, 2) + '\n';
}
async function main() {
const root = process.cwd();
writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema());
writeFileSync(join(root, 'apps/api/openapi.json'), await openApi());
}
main().then(
() => process.exit(0),
(error) => {
console.error(error);
process.exit(1);
},
);

View File

@@ -0,0 +1,21 @@
/*
* Lets Node run the TypeScript sources of the monorepo directly: compiles
* them on the fly (decorator metadata included, which Nest needs) and
* resolves the `@task-stream/*` aliases of tsconfig.base.json.
*/
const { join } = require('node:path');
const root = join(__dirname, '../../..');
require('ts-node').register({
project: join(root, 'apps/api/tsconfig.app.json'),
transpileOnly: true,
compilerOptions: {
module: 'commonjs',
moduleResolution: 'node10',
ignoreDeprecations: '6.0',
},
});
require('tsconfig-paths').register({
baseUrl: root,
paths: require(join(root, 'tsconfig.base.json')).compilerOptions.paths,
});

View File

@@ -42,4 +42,8 @@ export const configSchema = Joi.object({
SWAGGER_ENABLED: Joi.boolean().default(true),
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
// Attached files: where they are kept (a volume in Docker) and how big one can be.
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
});

View File

@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
import {
PIREON_BEARER,
PIREON_OAUTH,
@@ -35,19 +35,19 @@ show the previous version (or 404 right after a create).
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
`.trim();
/**
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
*/
export function setupOpenApi(app: INestApplication): void {
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
export interface OpenApiSettings {
pireonBaseUrl: string;
scopes: string[];
passwordGrant: boolean;
}
const pireon =
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
/** The OpenAPI document of the REST controllers. Needs no database. */
export function buildOpenApiDocument(
app: INestApplication,
settings: OpenApiSettings,
): OpenAPIObject {
const pireon = settings.pireonBaseUrl;
const scopes = settings.scopes;
const scopeDocs = Object.fromEntries(
scopes.map((scope) => [
scope,
@@ -76,7 +76,7 @@ export function setupOpenApi(app: INestApplication): void {
refreshUrl: tokenUrl,
scopes: scopeDocs,
},
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
...(settings.passwordGrant
? {
password: {
tokenUrl,
@@ -125,6 +125,27 @@ export function setupOpenApi(app: INestApplication): void {
}
}
return document;
}
/**
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
*/
export function setupOpenApi(app: INestApplication): void {
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
const document = buildOpenApiDocument(app, {
pireonBaseUrl:
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
scopes,
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
});
const file = config.get<string>('OPENAPI_FILE');
if (file) {
writeFileSync(

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@ server {
root /usr/share/nginx/html;
location /api/ {
# Attachments: the API takes files up to ATTACHMENTS_MAX_MB (25 MB by default).
client_max_body_size 30m;
proxy_pass http://task-stream-api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;