diff --git a/.env.example b/.env.example index 2735888..a182a1c 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,10 @@ GRAPHQL_PLAYGROUND=true SWAGGER_ENABLED=true OPENAPI_FILE=apps/api/openapi.json +# Files attached to tickets: folder (a volume in Docker) and size limit per file. +ATTACHMENTS_DIR=data/attachments +ATTACHMENTS_MAX_MB=25 + # Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) NEXUS_TOKEN= diff --git a/.gitignore b/.gitignore index ad4745e..f722141 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ Thumbs.db __screenshots__/ vitest.config.*.timestamp*!apps/web/.env + +# Attached files in development (ATTACHMENTS_DIR) +/data diff --git a/Dockerfile b/Dockerfile index 4265b70..2b01a50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ ENV NODE_ENV=production PORT=3000 COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force COPY --from=build-api /workspace/dist/apps/api/main.js ./ +# Attached files (a volume in docker-compose), writable by the user that runs the API. +RUN mkdir -p /data/attachments && chown node:node /data/attachments +ENV ATTACHMENTS_DIR=/data/attachments USER node EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \ diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c92f8e4..59e1a7c 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": [ diff --git a/apps/api/project.json b/apps/api/project.json index 77b1276..6519736 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -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 } } } diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 22ce2e9..c801359 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -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)""" diff --git a/apps/api/scripts/contracts.ts b/apps/api/scripts/contracts.ts new file mode 100644 index 0000000..083ed68 --- /dev/null +++ b/apps/api/scripts/contracts.ts @@ -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 { + 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(); + 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 { + // 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); + }, +); diff --git a/apps/api/scripts/register.cjs b/apps/api/scripts/register.cjs new file mode 100644 index 0000000..88e0cb3 --- /dev/null +++ b/apps/api/scripts/register.cjs @@ -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, +}); diff --git a/apps/api/src/app/config.schema.ts b/apps/api/src/app/config.schema.ts index 171fdde..ebc73b3 100644 --- a/apps/api/src/app/config.schema.ts +++ b/apps/api/src/app/config.schema.ts @@ -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), }); diff --git a/apps/api/src/app/openapi.ts b/apps/api/src/app/openapi.ts index 1b465e5..8efe621 100644 --- a/apps/api/src/app/openapi.ts +++ b/apps/api/src/app/openapi.ts @@ -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('SWAGGER_ENABLED')) return; +export interface OpenApiSettings { + pireonBaseUrl: string; + scopes: string[]; + passwordGrant: boolean; +} - const pireon = - config.get('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev'; - const scopes = (config.get('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('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('SWAGGER_ENABLED')) return; + + const scopes = (config.get('PIREON_SCOPES') ?? '') + .split(/[\s,]+/) + .filter(Boolean); + const document = buildOpenApiDocument(app, { + pireonBaseUrl: + config.get('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev', + scopes, + passwordGrant: !!config.get('PIREON_PASSWORD_GRANT'), + }); + const file = config.get('OPENAPI_FILE'); if (file) { writeFileSync( diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 9a014bc..8c305ef 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -4,4289 +4,4543 @@ */ export interface paths { - '/api/health': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** API and databases status */ + get: operations["HealthController_check"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** API and databases status */ - get: operations['HealthController_check']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/auth/config': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/auth/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** How to sign in with Pireon (null when not configured) */ + get: operations["AuthController_config"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** How to sign in with Pireon (null when not configured) */ - get: operations['AuthController_config']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/auth/token': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/auth/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token) + * @description For browsers (Pireon has no CORS) and Swagger UI: adds the client secret server-side. Apps with their own Pireon OAuth client can get tokens from Pireon directly; any valid Pireon access token is accepted by this API. + */ + post: operations["AuthController_token"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - /** - * Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token) - * @description For browsers (Pireon has no CORS) and Swagger UI: adds the client secret server-side. Apps with their own Pireon OAuth client can get tokens from Pireon directly; any valid Pireon access token is accepted by this API. - */ - post: operations['AuthController_token']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/users/me': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The user behind the access token */ + get: operations["UsersController_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** The user behind the access token */ - get: operations['UsersController_me']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/users': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Pireon users (to assign tickets or add members) + * @description Needs the `users:read` scope in the access token. + */ + get: operations["UsersController_search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** - * Search Pireon users (to assign tickets or add members) - * @description Needs the `users:read` scope in the access token. - */ - get: operations['UsersController_search']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/users/{id}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A Pireon user */ + get: operations["UsersController_findOne"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** A Pireon user */ - get: operations['UsersController_findOne']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/webhooks/pireon': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/webhooks/pireon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pireon events (called by Pireon, not by apps) + * @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set. + */ + post: operations["PireonWebhookController_receive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - /** - * Pireon events (called by Pireon, not by apps) - * @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set. - */ - post: operations['PireonWebhookController_receive']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Projects where I am a member */ + get: operations["ProjectsController_mine"]; + put?: never; + /** Create a project (you become its owner) */ + post: operations["ProjectsController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Projects where I am a member */ - get: operations['ProjectsController_mine']; - put?: never; - /** Create a project (you become its owner) */ - post: operations['ProjectsController_create']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A project with its members and settings */ + get: operations["ProjectsController_findOne"]; + put?: never; + post?: never; + /** + * Delete a project (owner only) + * @description Deletes the project and, right after (asynchronously, through a saga), its tickets, comments and releases; links from tickets of other projects are removed and the key becomes free. Cannot be undone from the API. `confirm` must be the project key. + */ + delete: operations["ProjectsController_remove"]; + options?: never; + head?: never; + /** Rename, describe or make public/private a project (owner/admin) */ + patch: operations["ProjectsController_update"]; + trace?: never; }; - /** A project with its members and settings */ - get: operations['ProjectsController_findOne']; - put?: never; - post?: never; - /** - * Delete a project (owner only) - * @description Deletes the project and, right after (asynchronously, through a saga), its tickets, comments and releases; links from tickets of other projects are removed and the key becomes free. Cannot be undone from the API. `confirm` must be the project key. - */ - delete: operations['ProjectsController_remove']; - options?: never; - head?: never; - /** Rename, describe or make public/private a project (owner/admin) */ - patch: operations['ProjectsController_update']; - trace?: never; - }; - '/api/projects/{project}/settings': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Statuses, transitions, ticket types and areas (owner/admin) + * @description Replaces the whole configuration (send what GET /projects/{project} returns, changed). Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them. + */ + put: operations["ProjectsController_updateSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - /** - * Statuses, transitions, ticket types and areas (owner/admin) - * @description Replaces the whole configuration (send what GET /projects/{project} returns, changed). Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them. - */ - put: operations['ProjectsController_updateSettings']; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/board': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/board": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Kanban board: one column per status */ + get: operations["ProjectsController_board"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Kanban board: one column per status */ - get: operations['ProjectsController_board']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/stats': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tracking dashboard: counts by status, type, person… */ + get: operations["ProjectsController_stats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Tracking dashboard: counts by status, type, person… */ - get: operations['ProjectsController_stats']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/analytics': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/analytics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Statistics of the last `days` days + * @description Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail. + */ + get: operations["ProjectsController_analytics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** - * Statistics of the last `days` days - * @description Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail. - */ - get: operations['ProjectsController_analytics']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/activity': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Activity feed (newest first) */ + get: operations["ProjectsController_activity"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Activity feed (newest first) */ - get: operations['ProjectsController_activity']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/members': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add a Pireon user to the project (owner/admin) + * @description The user must exist and be active in Pireon. + */ + post: operations["ProjectsController_addMember"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - /** - * Add a Pireon user to the project (owner/admin) - * @description The user must exist and be active in Pireon. - */ - post: operations['ProjectsController_addMember']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/members/{userId}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a member (owner/admin, or yourself to leave) */ + delete: operations["ProjectsController_removeMember"]; + options?: never; + head?: never; + /** Change a member's role (owner/admin) */ + patch: operations["ProjectsController_changeMemberRole"]; + trace?: never; }; - get?: never; - put?: never; - post?: never; - /** Remove a member (owner/admin, or yourself to leave) */ - delete: operations['ProjectsController_removeMember']; - options?: never; - head?: never; - /** Change a member's role (owner/admin) */ - patch: operations['ProjectsController_changeMemberRole']; - trace?: never; - }; - '/api/me/issues': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/me/issues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * My work: tickets across all my projects + * @description Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA). + */ + get: operations["IssuesController_mine"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** - * My work: tickets across all my projects - * @description Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA). - */ - get: operations['IssuesController_mine']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/issues': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/issues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search the tickets of a project */ + get: operations["IssuesController_search"]; + put?: never; + /** + * Create a ticket + * @description Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13). + */ + post: operations["IssuesController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Search the tickets of a project */ - get: operations['IssuesController_search']; - put?: never; - /** - * Create a ticket - * @description Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13). - */ - post: operations['IssuesController_create']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A ticket */ + get: operations["IssuesController_findOne"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Edit title, description, type, priority, area, labels… */ + patch: operations["IssuesController_update"]; + trace?: never; }; - /** A ticket */ - get: operations['IssuesController_findOne']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** Edit title, description, type, priority, area, labels… */ - patch: operations['IssuesController_update']; - trace?: never; - }; - '/api/issues/{issue}/status': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move the ticket through the workflow (incl. QA) + * @description Only the transitions in `allowedTransitions` are accepted (422 otherwise). QA_FAILED and CANCELLED need a reason. + */ + put: operations["IssuesController_changeStatus"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - /** - * Move the ticket through the workflow (incl. QA) - * @description Only the transitions in `allowedTransitions` are accepted (422 otherwise). QA_FAILED and CANCELLED need a reason. - */ - put: operations['IssuesController_changeStatus']; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}/assignee': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/assignee": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Assign to a project member (null to unassign) */ + put: operations["IssuesController_assign"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - /** Assign to a project member (null to unassign) */ - put: operations['IssuesController_assign']; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}/release': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/release": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Put the ticket in a planned release (null takes it out) */ + put: operations["IssuesController_setRelease"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - /** Put the ticket in a planned release (null takes it out) */ - put: operations['IssuesController_setRelease']; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}/links': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Relate tickets (FE ↔ BE ↔ QA) + * @description e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects. + */ + post: operations["IssuesController_link"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - /** - * Relate tickets (FE ↔ BE ↔ QA) - * @description e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects. - */ - post: operations['IssuesController_link']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}/links/{type}/{target}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/links/{type}/{target}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a relation */ + delete: operations["IssuesController_unlink"]; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - post?: never; - /** Remove a relation */ - delete: operations['IssuesController_unlink']; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}/comments': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Comments of the ticket (oldest first) */ + get: operations["IssuesController_listComments"]; + put?: never; + /** Comment on the ticket */ + post: operations["IssuesController_addComment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Comments of the ticket (oldest first) */ - get: operations['IssuesController_listComments']; - put?: never; - /** Comment on the ticket */ - post: operations['IssuesController_addComment']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/issues/{issue}/history': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** History of the ticket (newest first) */ + get: operations["IssuesController_history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** History of the ticket (newest first) */ - get: operations['IssuesController_history']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/comments/{commentId}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/attachments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Attach a file to a ticket (members) + * @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. + */ + post: operations["AttachmentsController_attach"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - post?: never; - /** Delete a comment (author or project admin) */ - delete: operations['CommentsController_delete']; - options?: never; - head?: never; - /** Edit a comment (author only) */ - patch: operations['CommentsController_edit']; - trace?: never; - }; - '/api/me/releases': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/issues/{issue}/attachments/{attachmentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download one of a ticket's files */ + get: operations["AttachmentsController_download"]; + put?: never; + post?: never; + /** Remove a file (whoever uploaded it, or an owner/admin) */ + delete: operations["AttachmentsController_remove"]; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Planned releases of all my projects, soonest target date first */ - get: operations['ReleasesController_upcoming']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/projects/{project}/releases': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/comments/{commentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a comment (author or project admin) */ + delete: operations["CommentsController_delete"]; + options?: never; + head?: never; + /** Edit a comment (author only) */ + patch: operations["CommentsController_edit"]; + trace?: never; }; - /** Releases of a project */ - get: operations['ReleasesController_list']; - put?: never; - /** Plan a release (owner/admin) */ - post: operations['ReleasesController_create']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/releases/{releaseId}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/me/releases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Planned releases of all my projects, soonest target date first */ + get: operations["ReleasesController_upcoming"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** A release */ - get: operations['ReleasesController_findOne']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** Rename, describe or move the target date */ - patch: operations['ReleasesController_update']; - trace?: never; - }; - '/api/releases/{releaseId}/progress': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/projects/{project}/releases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Releases of a project */ + get: operations["ReleasesController_list"]; + put?: never; + /** Plan a release (owner/admin) */ + post: operations["ReleasesController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Finished tickets out of the total */ - get: operations['ReleasesController_progress']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/releases/{releaseId}/burnup': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/releases/{releaseId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A release */ + get: operations["ReleasesController_findOne"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Rename, describe or move the target date */ + patch: operations["ReleasesController_update"]; + trace?: never; }; - /** Burnup: tickets in the release and finished ones, day by day */ - get: operations['ReleasesController_burnup']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/releases/{releaseId}/issues': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/releases/{releaseId}/progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Finished tickets out of the total */ + get: operations["ReleasesController_progress"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** Tickets included in the release */ - get: operations['ReleasesController_issuesOf']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/releases/{releaseId}/publish': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/releases/{releaseId}/burnup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Burnup: tickets in the release and finished ones, day by day */ + get: operations["ReleasesController_burnup"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - /** - * Ship the release - * @description Fails (422) while it has unfinished tickets unless `force` is true. - */ - post: operations['ReleasesController_publish']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/releases/{releaseId}/archive': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/releases/{releaseId}/issues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tickets included in the release */ + get: operations["ReleasesController_issuesOf"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - get?: never; - put?: never; - /** Archive a planned release (its tickets leave it) */ - post: operations['ReleasesController_archive']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/explore/projects': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + "/api/releases/{releaseId}/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Ship the release + * @description Fails (422) while it has unfinished tickets unless `force` is true. + */ + post: operations["ReleasesController_publish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/releases/{releaseId}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive a planned release (its tickets leave it) */ + post: operations["ReleasesController_archive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/explore/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public projects (no token needed) + * @description Their board, tickets, comments, releases, activity and statistics can be read without a token too. + */ + get: operations["ExploreController_publicProjects"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** - * Public projects (no token needed) - * @description Their board, tickets, comments, releases, activity and statistics can be read without a token too. - */ - get: operations['ExploreController_publicProjects']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { - schemas: { - AuthorizationConfigDto: { - /** @example https://oauth.pireon.dev/oauth/authorize */ - authorizeUrl: string; - /** @description Public id of the authorization_code client ("Sign in with Pireon"); null when only email + password is configured */ - clientId: string | null; - /** - * @example [ - * "users:read" - * ] - */ - scopes: string[]; - /** @description Whether email + password sign-in is allowed */ - passwordGrant: boolean; + schemas: { + AuthorizationConfigDto: { + /** @example https://oauth.pireon.dev/oauth/authorize */ + authorizeUrl: string; + /** @description Public id of the authorization_code client ("Sign in with Pireon"); null when only email + password is configured */ + clientId: string | null; + /** + * @example [ + * "users:read" + * ] + */ + scopes: string[]; + /** @description Whether email + password sign-in is allowed */ + passwordGrant: boolean; + }; + IssueTokenDto: { + /** @enum {string} */ + grant_type: "authorization_code" | "password" | "refresh_token"; + /** @description authorization_code: code returned by Pireon */ + code?: string; + /** @description authorization_code: PKCE verifier */ + code_verifier?: string; + /** @description authorization_code: redirect URI used to authorize */ + redirect_uri?: string; + /** @description password: Pireon email */ + username?: string; + /** @description password: Pireon password */ + password?: string; + /** @description refresh_token */ + refresh_token?: string; + }; + TokenResponseDto: { + access_token: string; + refresh_token: string | null; + /** @description Seconds */ + expires_in: number; + /** @example Bearer */ + token_type: string; + }; + MeDto: { + /** @description Pireon user id */ + id: string; + name: string; + email: string; + isActive: boolean; + /** @description Global roles in Pireon */ + roles: string[]; + /** @description Roles inside the task-stream OAuth client */ + appRoles: string[]; + }; + ApiErrorDto: { + /** @example 404 */ + statusCode: number; + /** + * @description Domain error code + * @enum {string} + */ + code?: "NOT_FOUND" | "FORBIDDEN" | "VALIDATION" | "CONFLICT" | "BUSINESS_RULE"; + /** + * @description Human message (a list of messages for invalid input) + * @example Issue not found + */ + message: string | string[]; + }; + UserDto: { + /** @description Pireon user id */ + id: string; + name: string; + email: string; + isActive: boolean; + }; + UserRefDto: { + /** @description Pireon user id */ + id: string; + name: string; + }; + /** @enum {string} */ + ProjectRole: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER"; + ProjectMemberDto: { + user: components["schemas"]["UserRefDto"]; + role: components["schemas"]["ProjectRole"]; + }; + /** @enum {string} */ + StatusCategory: "TODO" | "IN_PROGRESS" | "QA" | "DONE" | "CANCELLED"; + /** @enum {string} */ + SettingColor: "slate" | "stone" | "violet" | "blue" | "sky" | "teal" | "green" | "amber" | "orange" | "red" | "pink"; + WorkflowStatusDto: { + /** + * @description Stable id on tickets + * @example IN_REVIEW + */ + id: string; + /** @example En revisión */ + name: string; + category: components["schemas"]["StatusCategory"]; + color: components["schemas"]["SettingColor"]; + /** @description Entering it needs a reason */ + requiresReason: boolean; + /** @description No new tickets or transitions into it */ + archived: boolean; + }; + StatusTransitionsDto: { + /** @example IN_QA */ + from: string; + /** + * @example [ + * "DONE", + * "QA_FAILED" + * ] + */ + to: string[]; + }; + /** @enum {string} */ + TypeIcon: "bug" | "feature" | "palette" | "task" | "qa" | "flag" | "star-fill" | "rocket" | "document" | "light-bulb" | "key" | "support"; + IssueTypeOptionDto: { + /** @example BUG */ + id: string; + /** @example Bug */ + name: string; + icon: components["schemas"]["TypeIcon"]; + color: components["schemas"]["SettingColor"]; + archived: boolean; + }; + AreaOptionDto: { + /** @example FRONTEND */ + id: string; + /** @example Frontend */ + name: string; + archived: boolean; + }; + ProjectSettingsDto: { + /** @description In board order */ + statuses: components["schemas"]["WorkflowStatusDto"][]; + transitions: components["schemas"]["StatusTransitionsDto"][]; + issueTypes: components["schemas"]["IssueTypeOptionDto"][]; + areas: components["schemas"]["AreaOptionDto"][]; + }; + ProjectDto: { + /** Format: uuid */ + id: string; + /** + * @description Prefix of the ticket keys + * @example WEB + */ + key: string; + /** @example Checkout revamp */ + name: string; + description: string | null; + /** @description Readable by anyone, also without a token */ + isPublic: boolean; + owner: components["schemas"]["UserRefDto"]; + members: components["schemas"]["ProjectMemberDto"][]; + settings: components["schemas"]["ProjectSettingsDto"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + /** @description Version of the aggregate (event count) */ + version: number; + }; + CreateProjectDto: { + /** + * @description 2-10 letters/digits starting with a letter → tickets WEB-1, WEB-2… + * @example WEB + */ + key: string; + /** @example Checkout revamp */ + name: string; + description?: string | null; + /** + * @description Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics) + * @default false + */ + isPublic: boolean; + }; + CommandResultDto: { + /** @description Id of the aggregate written */ + id: string; + /** + * @description Human key when one was assigned (project or issue) + * @example WEB-13 + */ + key?: string; + /** @description Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create). */ + version: number; + }; + UpdateProjectDto: { + /** @example Checkout revamp */ + name?: string; + description?: string | null; + /** + * @description Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics) + * @default false + */ + isPublic: boolean; + }; + WorkflowStatusBodyDto: { + /** + * @description Never change an existing id + * @example IN_REVIEW + */ + id: string; + /** @example En revisión */ + name: string; + category: components["schemas"]["StatusCategory"]; + color: components["schemas"]["SettingColor"]; + /** @default false */ + requiresReason: boolean; + /** @default false */ + archived: boolean; + }; + StatusTransitionsBodyDto: { + /** @example IN_QA */ + from: string; + /** + * @example [ + * "DONE", + * "QA_FAILED" + * ] + */ + to: string[]; + }; + IssueTypeOptionBodyDto: { + /** @example SPIKE */ + id: string; + /** @example Spike */ + name: string; + icon: components["schemas"]["TypeIcon"]; + color: components["schemas"]["SettingColor"]; + /** @default false */ + archived: boolean; + }; + AreaOptionBodyDto: { + /** @example DATA */ + id: string; + /** @example Datos */ + name: string; + /** @default false */ + archived: boolean; + }; + ProjectSettingsBodyDto: { + /** @description In board order. Values cannot be removed: archive them instead. */ + statuses: components["schemas"]["WorkflowStatusBodyDto"][]; + transitions: components["schemas"]["StatusTransitionsBodyDto"][]; + issueTypes: components["schemas"]["IssueTypeOptionBodyDto"][]; + areas: components["schemas"]["AreaOptionBodyDto"][]; + }; + /** @enum {string} */ + IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + ReleaseRefDto: { + /** Format: uuid */ + id: string; + /** @example v1.4.0 */ + name: string; + }; + /** @enum {string} */ + IssueLinkType: "BLOCKS" | "DEPENDS_ON" | "TESTS" | "DUPLICATES" | "RELATES_TO"; + /** + * @description OUTWARD: this issue → other. INWARD: other → this issue. + * @enum {string} + */ + LinkDirection: "OUTWARD" | "INWARD"; + IssueLinkDto: { + type: components["schemas"]["IssueLinkType"]; + /** @description OUTWARD: this issue → other. INWARD: other → this issue. */ + direction: components["schemas"]["LinkDirection"]; + /** Format: uuid */ + issueId: string; + /** @example WEB-3 */ + issueKey: string; + }; + AttachmentDto: { + /** Format: uuid */ + id: string; + /** @example captura-login.png */ + fileName: string; + /** @example image/png */ + mimeType: string; + /** + * @description Bytes + * @example 48213 + */ + size: number; + uploadedBy: components["schemas"]["UserRefDto"]; + /** Format: date-time */ + uploadedAt: string; + /** + * Format: uuid + * @description The comment it was attached to, if any + */ + commentId: string | null; + }; + IssueDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @example WEB */ + projectKey: string; + /** @example 12 */ + number: number; + /** + * @description Human key + * @example WEB-12 + */ + key: string; + /** + * @description A ticket type id + * @example BUG + */ + type: string; + /** @example FRONTEND */ + area: string | null; + title: string; + description: string | null; + /** + * @description A status id + * @example IN_PROGRESS + */ + status: string; + statusCategory: components["schemas"]["StatusCategory"]; + /** @description Statuses this ticket can move to next (project's workflow) */ + allowedTransitions: string[]; + priority: components["schemas"]["IssuePriority"]; + reporter: components["schemas"]["UserRefDto"]; + assignee: components["schemas"]["UserRefDto"] | null; + release: components["schemas"]["ReleaseRefDto"] | null; + labels: string[]; + /** @description Figma frame */ + figmaUrl: string | null; + storyPoints: number | null; + /** Format: date */ + dueDate: string | null; + links: components["schemas"]["IssueLinkDto"][]; + attachments: components["schemas"]["AttachmentDto"][]; + commentCount: number; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + version: number; + }; + BoardColumnDto: { + /** @example IN_PROGRESS */ + status: string; + issues: components["schemas"]["IssueDto"][]; + /** @description Tickets in the column (may exceed `issues`) */ + total: number; + }; + CountByStatusDto: { + /** + * @description Status id + * @example IN_PROGRESS + */ + key: string; + count: number; + }; + CountByKeyDto: { + /** @description Type, priority or area ("NONE" = unset) */ + key: string; + count: number; + }; + CountByAssigneeDto: { + /** @description Null = unassigned */ + user: components["schemas"]["UserRefDto"] | null; + count: number; + }; + ProjectStatsDto: { + total: number; + /** @description Not done nor cancelled */ + open: number; + byStatus: components["schemas"]["CountByStatusDto"][]; + byType: components["schemas"]["CountByKeyDto"][]; + byPriority: components["schemas"]["CountByKeyDto"][]; + byArea: components["schemas"]["CountByKeyDto"][]; + /** @description Open tickets per person */ + byAssignee: components["schemas"]["CountByAssigneeDto"][]; + }; + CreatedResolvedDto: { + /** + * Format: date + * @description Monday of the week + */ + week: string; + created: number; + resolved: number; + }; + FlowSeriesDto: { + /** @example IN_PROGRESS */ + status: string; + /** @description One value per date */ + counts: number[]; + }; + CumulativeFlowDto: { + dates: string[]; + series: components["schemas"]["FlowSeriesDto"][]; + }; + DurationStatsDto: { + count: number; + averageDays: number | null; + /** @description 85% of tickets take at most this long */ + p85Days: number | null; + }; + ThroughputDto: { + /** Format: date */ + week: string; + user: components["schemas"]["UserRefDto"] | null; + count: number; + }; + QaStatsDto: { + /** @description Left QA to a done status */ + passed: number; + /** @description Left QA back to work */ + failed: number; + failureRate: number | null; + }; + ProjectAnalyticsDto: { + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + createdVsResolved: components["schemas"]["CreatedResolvedDto"][]; + /** @description Tickets in each status at the end of every day */ + cumulativeFlow: components["schemas"]["CumulativeFlowDto"]; + /** @description Start of work → done */ + cycleTime: components["schemas"]["DurationStatsDto"]; + /** @description Creation → done */ + leadTime: components["schemas"]["DurationStatsDto"]; + throughput: components["schemas"]["ThroughputDto"][]; + qa: components["schemas"]["QaStatsDto"]; + }; + ActivityDto: { + id: string; + /** @example IssueStatusChanged */ + type: string; + /** Format: uuid */ + projectId: string; + issueId: string | null; + /** @example WEB-12 */ + issueKey: string | null; + actor: components["schemas"]["UserRefDto"]; + /** @example Ana movió WEB-12 de En curso a En revisión */ + summary: string; + /** Format: date-time */ + occurredAt: string; + }; + AddMemberDto: { + /** @description Pireon user id (see GET /users) */ + userId: string; + /** @default MEMBER */ + role: components["schemas"]["ProjectRole"]; + }; + ChangeMemberRoleDto: { + role: components["schemas"]["ProjectRole"]; + }; + IssuePageDto: { + items: components["schemas"]["IssueDto"][]; + /** @description Total matches (for pagination) */ + total: number; + }; + CreateIssueDto: { + description?: string | null; + /** + * @description An area id of the project's settings + * @example FRONTEND + */ + area?: string | null; + priority?: components["schemas"]["IssuePriority"]; + labels?: string[]; + /** @description Figma frame (https) for UI changes */ + figmaUrl?: string | null; + storyPoints?: number | null; + /** Format: date */ + dueDate?: string | null; + /** + * @description A ticket type id of the project's settings + * @example BUG + */ + type: string; + /** @example El botón de pagar no responde en Safari */ + title: string; + /** @description Pireon user id */ + assigneeId?: string | null; + /** Format: uuid */ + releaseId?: string | null; + }; + UpdateIssueDto: { + description?: string | null; + /** + * @description An area id of the project's settings + * @example FRONTEND + */ + area?: string | null; + priority?: components["schemas"]["IssuePriority"]; + labels?: string[]; + /** @description Figma frame (https) for UI changes */ + figmaUrl?: string | null; + storyPoints?: number | null; + /** Format: date */ + dueDate?: string | null; + /** + * @description A ticket type id + * @example FEATURE + */ + type?: string; + title?: string; + }; + ChangeStatusDto: { + /** + * @description A status id of the project's settings, among the ticket's allowedTransitions + * @example IN_REVIEW + */ + status: string; + /** @description Required by statuses with requiresReason (e.g. QA_FAILED) */ + reason?: string | null; + }; + AssignIssueDto: { + /** @description Pireon user id of a project member, or null to unassign */ + assigneeId: string | null; + }; + SetIssueReleaseDto: { + /** + * Format: uuid + * @description Planned release, or null to take the ticket out + */ + releaseId: string | null; + }; + LinkIssueDto: { + type: components["schemas"]["IssueLinkType"]; + /** + * @description Other ticket: id or key + * @example API-3 + */ + targetIssue: string; + }; + CommentDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + issueId: string; + author: components["schemas"]["UserRefDto"]; + body: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + editedAt: string | null; + version: number; + }; + CommentPageDto: { + items: components["schemas"]["CommentDto"][]; + total: number; + }; + CommentBodyDto: { + /** @example Reproducido en Safari 17. */ + body: string; + }; + AttachFileDto: { + /** + * Format: binary + * @description The file + */ + file: string; + /** + * Format: uuid + * @description One of the ticket's comments, to show the file under it + */ + commentId?: string; + }; + /** @enum {string} */ + ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED"; + ReleaseDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @example v1.4.0 */ + name: string; + description: string | null; + /** Format: date */ + targetDate: string | null; + status: components["schemas"]["ReleaseStatus"]; + /** Format: date-time */ + releasedAt: string | null; + /** Format: date-time */ + createdAt: string; + version: number; + }; + CreateReleaseDto: { + /** @example v1.4.0 */ + name: string; + description?: string | null; + /** + * Format: date + * @example 2026-11-14 + */ + targetDate?: string | null; + }; + UpdateReleaseDto: { + /** @example v1.4.0 */ + name?: string; + description?: string | null; + /** + * Format: date + * @example 2026-11-14 + */ + targetDate?: string | null; + }; + ReleaseProgressDto: { + total: number; + finished: number; + percent: number; + byStatus: components["schemas"]["CountByStatusDto"][]; + }; + ReleaseBurnupDto: { + /** Format: uuid */ + releaseId: string; + /** Format: date */ + targetDate: string | null; + dates: string[]; + /** @description Tickets in the release (not cancelled) */ + scope: number[]; + done: number[]; + }; + PublishReleaseDto: { + /** + * @description Publish even with unfinished tickets + * @default false + */ + force: boolean; + }; }; - IssueTokenDto: { - /** @enum {string} */ - grant_type: 'authorization_code' | 'password' | 'refresh_token'; - /** @description authorization_code: code returned by Pireon */ - code?: string; - /** @description authorization_code: PKCE verifier */ - code_verifier?: string; - /** @description authorization_code: redirect URI used to authorize */ - redirect_uri?: string; - /** @description password: Pireon email */ - username?: string; - /** @description password: Pireon password */ - password?: string; - /** @description refresh_token */ - refresh_token?: string; - }; - TokenResponseDto: { - access_token: string; - refresh_token: string | null; - /** @description Seconds */ - expires_in: number; - /** @example Bearer */ - token_type: string; - }; - MeDto: { - /** @description Pireon user id */ - id: string; - name: string; - email: string; - isActive: boolean; - /** @description Global roles in Pireon */ - roles: string[]; - /** @description Roles inside the task-stream OAuth client */ - appRoles: string[]; - }; - ApiErrorDto: { - /** @example 404 */ - statusCode: number; - /** - * @description Domain error code - * @enum {string} - */ - code?: - | 'NOT_FOUND' - | 'FORBIDDEN' - | 'VALIDATION' - | 'CONFLICT' - | 'BUSINESS_RULE'; - /** - * @description Human message (a list of messages for invalid input) - * @example Issue not found - */ - message: string | string[]; - }; - UserDto: { - /** @description Pireon user id */ - id: string; - name: string; - email: string; - isActive: boolean; - }; - UserRefDto: { - /** @description Pireon user id */ - id: string; - name: string; - }; - /** @enum {string} */ - ProjectRole: 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER'; - ProjectMemberDto: { - user: components['schemas']['UserRefDto']; - role: components['schemas']['ProjectRole']; - }; - /** @enum {string} */ - StatusCategory: 'TODO' | 'IN_PROGRESS' | 'QA' | 'DONE' | 'CANCELLED'; - /** @enum {string} */ - SettingColor: - | 'slate' - | 'stone' - | 'violet' - | 'blue' - | 'sky' - | 'teal' - | 'green' - | 'amber' - | 'orange' - | 'red' - | 'pink'; - WorkflowStatusDto: { - /** - * @description Stable id on tickets - * @example IN_REVIEW - */ - id: string; - /** @example En revisión */ - name: string; - category: components['schemas']['StatusCategory']; - color: components['schemas']['SettingColor']; - /** @description Entering it needs a reason */ - requiresReason: boolean; - /** @description No new tickets or transitions into it */ - archived: boolean; - }; - StatusTransitionsDto: { - /** @example IN_QA */ - from: string; - /** - * @example [ - * "DONE", - * "QA_FAILED" - * ] - */ - to: string[]; - }; - /** @enum {string} */ - TypeIcon: - | 'bug' - | 'feature' - | 'palette' - | 'task' - | 'qa' - | 'flag' - | 'star-fill' - | 'rocket' - | 'document' - | 'light-bulb' - | 'key' - | 'support'; - IssueTypeOptionDto: { - /** @example BUG */ - id: string; - /** @example Bug */ - name: string; - icon: components['schemas']['TypeIcon']; - color: components['schemas']['SettingColor']; - archived: boolean; - }; - AreaOptionDto: { - /** @example FRONTEND */ - id: string; - /** @example Frontend */ - name: string; - archived: boolean; - }; - ProjectSettingsDto: { - /** @description In board order */ - statuses: components['schemas']['WorkflowStatusDto'][]; - transitions: components['schemas']['StatusTransitionsDto'][]; - issueTypes: components['schemas']['IssueTypeOptionDto'][]; - areas: components['schemas']['AreaOptionDto'][]; - }; - ProjectDto: { - /** Format: uuid */ - id: string; - /** - * @description Prefix of the ticket keys - * @example WEB - */ - key: string; - /** @example Checkout revamp */ - name: string; - description: string | null; - /** @description Readable by anyone, also without a token */ - isPublic: boolean; - owner: components['schemas']['UserRefDto']; - members: components['schemas']['ProjectMemberDto'][]; - settings: components['schemas']['ProjectSettingsDto']; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - /** @description Version of the aggregate (event count) */ - version: number; - }; - CreateProjectDto: { - /** - * @description 2-10 letters/digits starting with a letter → tickets WEB-1, WEB-2… - * @example WEB - */ - key: string; - /** @example Checkout revamp */ - name: string; - description?: string | null; - /** - * @description Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics) - * @default false - */ - isPublic: boolean; - }; - CommandResultDto: { - /** @description Id of the aggregate written */ - id: string; - /** - * @description Human key when one was assigned (project or issue) - * @example WEB-13 - */ - key?: string; - /** @description Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create). */ - version: number; - }; - UpdateProjectDto: { - /** @example Checkout revamp */ - name?: string; - description?: string | null; - /** - * @description Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics) - * @default false - */ - isPublic: boolean; - }; - WorkflowStatusBodyDto: { - /** - * @description Never change an existing id - * @example IN_REVIEW - */ - id: string; - /** @example En revisión */ - name: string; - category: components['schemas']['StatusCategory']; - color: components['schemas']['SettingColor']; - /** @default false */ - requiresReason: boolean; - /** @default false */ - archived: boolean; - }; - StatusTransitionsBodyDto: { - /** @example IN_QA */ - from: string; - /** - * @example [ - * "DONE", - * "QA_FAILED" - * ] - */ - to: string[]; - }; - IssueTypeOptionBodyDto: { - /** @example SPIKE */ - id: string; - /** @example Spike */ - name: string; - icon: components['schemas']['TypeIcon']; - color: components['schemas']['SettingColor']; - /** @default false */ - archived: boolean; - }; - AreaOptionBodyDto: { - /** @example DATA */ - id: string; - /** @example Datos */ - name: string; - /** @default false */ - archived: boolean; - }; - ProjectSettingsBodyDto: { - /** @description In board order. Values cannot be removed: archive them instead. */ - statuses: components['schemas']['WorkflowStatusBodyDto'][]; - transitions: components['schemas']['StatusTransitionsBodyDto'][]; - issueTypes: components['schemas']['IssueTypeOptionBodyDto'][]; - areas: components['schemas']['AreaOptionBodyDto'][]; - }; - /** @enum {string} */ - IssuePriority: 'LOWEST' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; - ReleaseRefDto: { - /** Format: uuid */ - id: string; - /** @example v1.4.0 */ - name: string; - }; - /** @enum {string} */ - IssueLinkType: - | 'BLOCKS' - | 'DEPENDS_ON' - | 'TESTS' - | 'DUPLICATES' - | 'RELATES_TO'; - /** - * @description OUTWARD: this issue → other. INWARD: other → this issue. - * @enum {string} - */ - LinkDirection: 'OUTWARD' | 'INWARD'; - IssueLinkDto: { - type: components['schemas']['IssueLinkType']; - /** @description OUTWARD: this issue → other. INWARD: other → this issue. */ - direction: components['schemas']['LinkDirection']; - /** Format: uuid */ - issueId: string; - /** @example WEB-3 */ - issueKey: string; - }; - IssueDto: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @example WEB */ - projectKey: string; - /** @example 12 */ - number: number; - /** - * @description Human key - * @example WEB-12 - */ - key: string; - /** - * @description A ticket type id - * @example BUG - */ - type: string; - /** @example FRONTEND */ - area: string | null; - title: string; - description: string | null; - /** - * @description A status id - * @example IN_PROGRESS - */ - status: string; - statusCategory: components['schemas']['StatusCategory']; - /** @description Statuses this ticket can move to next (project's workflow) */ - allowedTransitions: string[]; - priority: components['schemas']['IssuePriority']; - reporter: components['schemas']['UserRefDto']; - assignee: components['schemas']['UserRefDto'] | null; - release: components['schemas']['ReleaseRefDto'] | null; - labels: string[]; - /** @description Figma frame */ - figmaUrl: string | null; - storyPoints: number | null; - /** Format: date */ - dueDate: string | null; - links: components['schemas']['IssueLinkDto'][]; - commentCount: number; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - version: number; - }; - BoardColumnDto: { - /** @example IN_PROGRESS */ - status: string; - issues: components['schemas']['IssueDto'][]; - /** @description Tickets in the column (may exceed `issues`) */ - total: number; - }; - CountByStatusDto: { - /** - * @description Status id - * @example IN_PROGRESS - */ - key: string; - count: number; - }; - CountByKeyDto: { - /** @description Type, priority or area ("NONE" = unset) */ - key: string; - count: number; - }; - CountByAssigneeDto: { - /** @description Null = unassigned */ - user: components['schemas']['UserRefDto'] | null; - count: number; - }; - ProjectStatsDto: { - total: number; - /** @description Not done nor cancelled */ - open: number; - byStatus: components['schemas']['CountByStatusDto'][]; - byType: components['schemas']['CountByKeyDto'][]; - byPriority: components['schemas']['CountByKeyDto'][]; - byArea: components['schemas']['CountByKeyDto'][]; - /** @description Open tickets per person */ - byAssignee: components['schemas']['CountByAssigneeDto'][]; - }; - CreatedResolvedDto: { - /** - * Format: date - * @description Monday of the week - */ - week: string; - created: number; - resolved: number; - }; - FlowSeriesDto: { - /** @example IN_PROGRESS */ - status: string; - /** @description One value per date */ - counts: number[]; - }; - CumulativeFlowDto: { - dates: string[]; - series: components['schemas']['FlowSeriesDto'][]; - }; - DurationStatsDto: { - count: number; - averageDays: number | null; - /** @description 85% of tickets take at most this long */ - p85Days: number | null; - }; - ThroughputDto: { - /** Format: date */ - week: string; - user: components['schemas']['UserRefDto'] | null; - count: number; - }; - QaStatsDto: { - /** @description Left QA to a done status */ - passed: number; - /** @description Left QA back to work */ - failed: number; - failureRate: number | null; - }; - ProjectAnalyticsDto: { - /** Format: date-time */ - from: string; - /** Format: date-time */ - to: string; - createdVsResolved: components['schemas']['CreatedResolvedDto'][]; - /** @description Tickets in each status at the end of every day */ - cumulativeFlow: components['schemas']['CumulativeFlowDto']; - /** @description Start of work → done */ - cycleTime: components['schemas']['DurationStatsDto']; - /** @description Creation → done */ - leadTime: components['schemas']['DurationStatsDto']; - throughput: components['schemas']['ThroughputDto'][]; - qa: components['schemas']['QaStatsDto']; - }; - ActivityDto: { - id: string; - /** @example IssueStatusChanged */ - type: string; - /** Format: uuid */ - projectId: string; - issueId: string | null; - /** @example WEB-12 */ - issueKey: string | null; - actor: components['schemas']['UserRefDto']; - /** @example Ana movió WEB-12 de En curso a En revisión */ - summary: string; - /** Format: date-time */ - occurredAt: string; - }; - AddMemberDto: { - /** @description Pireon user id (see GET /users) */ - userId: string; - /** @default MEMBER */ - role: components['schemas']['ProjectRole']; - }; - ChangeMemberRoleDto: { - role: components['schemas']['ProjectRole']; - }; - IssuePageDto: { - items: components['schemas']['IssueDto'][]; - /** @description Total matches (for pagination) */ - total: number; - }; - CreateIssueDto: { - description?: string | null; - /** - * @description An area id of the project's settings - * @example FRONTEND - */ - area?: string | null; - priority?: components['schemas']['IssuePriority']; - labels?: string[]; - /** @description Figma frame (https) for UI changes */ - figmaUrl?: string | null; - storyPoints?: number | null; - /** Format: date */ - dueDate?: string | null; - /** - * @description A ticket type id of the project's settings - * @example BUG - */ - type: string; - /** @example El botón de pagar no responde en Safari */ - title: string; - /** @description Pireon user id */ - assigneeId?: string | null; - /** Format: uuid */ - releaseId?: string | null; - }; - UpdateIssueDto: { - description?: string | null; - /** - * @description An area id of the project's settings - * @example FRONTEND - */ - area?: string | null; - priority?: components['schemas']['IssuePriority']; - labels?: string[]; - /** @description Figma frame (https) for UI changes */ - figmaUrl?: string | null; - storyPoints?: number | null; - /** Format: date */ - dueDate?: string | null; - /** - * @description A ticket type id - * @example FEATURE - */ - type?: string; - title?: string; - }; - ChangeStatusDto: { - /** - * @description A status id of the project's settings, among the ticket's allowedTransitions - * @example IN_REVIEW - */ - status: string; - /** @description Required by statuses with requiresReason (e.g. QA_FAILED) */ - reason?: string | null; - }; - AssignIssueDto: { - /** @description Pireon user id of a project member, or null to unassign */ - assigneeId: string | null; - }; - SetIssueReleaseDto: { - /** - * Format: uuid - * @description Planned release, or null to take the ticket out - */ - releaseId: string | null; - }; - LinkIssueDto: { - type: components['schemas']['IssueLinkType']; - /** - * @description Other ticket: id or key - * @example API-3 - */ - targetIssue: string; - }; - CommentDto: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - issueId: string; - author: components['schemas']['UserRefDto']; - body: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - editedAt: string | null; - version: number; - }; - CommentPageDto: { - items: components['schemas']['CommentDto'][]; - total: number; - }; - CommentBodyDto: { - /** @example Reproducido en Safari 17. */ - body: string; - }; - /** @enum {string} */ - ReleaseStatus: 'PLANNED' | 'RELEASED' | 'ARCHIVED'; - ReleaseDto: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - /** @example v1.4.0 */ - name: string; - description: string | null; - /** Format: date */ - targetDate: string | null; - status: components['schemas']['ReleaseStatus']; - /** Format: date-time */ - releasedAt: string | null; - /** Format: date-time */ - createdAt: string; - version: number; - }; - CreateReleaseDto: { - /** @example v1.4.0 */ - name: string; - description?: string | null; - /** - * Format: date - * @example 2026-11-14 - */ - targetDate?: string | null; - }; - UpdateReleaseDto: { - /** @example v1.4.0 */ - name?: string; - description?: string | null; - /** - * Format: date - * @example 2026-11-14 - */ - targetDate?: string | null; - }; - ReleaseProgressDto: { - total: number; - finished: number; - percent: number; - byStatus: components['schemas']['CountByStatusDto'][]; - }; - ReleaseBurnupDto: { - /** Format: uuid */ - releaseId: string; - /** Format: date */ - targetDate: string | null; - dates: string[]; - /** @description Tickets in the release (not cancelled) */ - scope: number[]; - done: number[]; - }; - PublishReleaseDto: { - /** - * @description Publish even with unfinished tickets - * @default false - */ - force: boolean; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; } export type $defs = Record; export interface operations { - HealthController_check: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; - }; - }; - /** @description A database is down */ - 503: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - AuthController_config: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['AuthorizationConfigDto']; - }; - }; - }; - }; - AuthController_token: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['IssueTokenDto']; - 'application/x-www-form-urlencoded': components['schemas']['IssueTokenDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['TokenResponseDto']; - }; - }; - /** @description Pireon rejected the grant */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - UsersController_me: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['MeDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - UsersController_search: { - parameters: { - query?: { - /** @description Name or email */ - search?: string; - limit?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - UsersController_findOne: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['UserDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - PireonWebhookController_receive: { - parameters: { - query?: never; - header: { - 'x-webhook-signature': string; - /** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */ - 'X-Webhook-Signature': string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Processed */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Missing or invalid signature */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - ProjectsController_mine: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ProjectDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_create: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CreateProjectDto']; - }; - }; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_findOne: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ProjectDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_remove: { - parameters: { - query: { - /** @description The project key, to confirm the deletion */ - confirm: string; - }; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_update: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UpdateProjectDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_updateSettings: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['ProjectSettingsBodyDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_board: { - parameters: { - query?: { - /** @description Only this release */ - releaseId?: string; - /** @description Only this person (Pireon user id) */ - assigneeId?: string; - /** @description An area id */ - area?: string; - perColumn?: number; - }; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['BoardColumnDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_stats: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ProjectStatsDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_analytics: { - parameters: { - query?: { - days?: number; - }; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ProjectAnalyticsDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_activity: { - parameters: { - query?: { - /** @description Only this ticket */ - issueId?: string; - limit?: number; - /** @description Cursor: entries older than this date */ - before?: string; - }; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ActivityDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_addMember: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['AddMemberDto']; - }; - }; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_removeMember: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - userId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ProjectsController_changeMemberRole: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - userId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['ChangeMemberRoleDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_mine: { - parameters: { - query?: { - limit?: number; - offset?: number; - /** @description true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA) */ - assignedToMe?: boolean; - /** @description Status ids. Repeat the parameter or separate with commas */ - status?: string[]; - /** @description Whatever each project calls its statuses */ - category?: components['schemas']['StatusCategory'][]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['IssuePageDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_search: { - parameters: { - query?: { - limit?: number; - offset?: number; - /** @description Status ids. Repeat the parameter or separate with commas */ - status?: string[]; - category?: components['schemas']['StatusCategory'][]; - /** @description Ticket type ids */ - type?: string[]; - /** @description Area ids */ - area?: string[]; - priority?: components['schemas']['IssuePriority'][]; - /** @description Pireon user id */ - assigneeId?: string; - /** @description Only tickets without assignee */ - unassigned?: boolean; - releaseId?: string; - label?: string[]; - /** @description Matches key, title or description */ - search?: string; - }; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['IssuePageDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_create: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CreateIssueDto']; - }; - }; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_findOne: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['IssueDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_update: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UpdateIssueDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_changeStatus: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['ChangeStatusDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_assign: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['AssignIssueDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_setRelease: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['SetIssueReleaseDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_link: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['LinkIssueDto']; - }; - }; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_unlink: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - type: components['schemas']['IssueLinkType']; - /** @description Other ticket: id or key */ - target: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_listComments: { - parameters: { - query?: { - limit?: number; - offset?: number; - }; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommentPageDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_addComment: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CommentBodyDto']; - }; - }; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - IssuesController_history: { - parameters: { - query?: { - limit?: number; - offset?: number; - }; - header?: never; - path: { - /** @description Ticket id or key */ - issue: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ActivityDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - CommentsController_delete: { - parameters: { - query?: never; - header?: never; - path: { - commentId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - CommentsController_edit: { - parameters: { - query?: never; - header?: never; - path: { - commentId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CommentBodyDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_upcoming: { - parameters: { - query?: { - limit?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ReleaseDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_list: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ReleaseDto'][]; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_create: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Project id or key */ - project: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['CreateReleaseDto']; - }; - }; - responses: { - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_findOne: { - parameters: { - query?: never; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ReleaseDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_update: { - parameters: { - query?: never; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['UpdateReleaseDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_progress: { - parameters: { - query?: never; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ReleaseProgressDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_burnup: { - parameters: { - query?: never; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ReleaseBurnupDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_issuesOf: { - parameters: { - query?: { - limit?: number; - offset?: number; - }; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['IssuePageDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_publish: { - parameters: { - query?: never; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['PublishReleaseDto']; - }; - }; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ReleasesController_archive: { - parameters: { - query?: never; - header?: never; - path: { - releaseId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['CommandResultDto']; - }; - }; - /** @description Invalid input */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Missing or rejected Pireon token */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not a member of the project (or not allowed) */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Someone changed it at the same time: retry */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - /** @description Business rule (e.g. a transition the workflow forbids) */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ApiErrorDto']; - }; - }; - }; - }; - ExploreController_publicProjects: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['ProjectDto'][]; + HealthController_check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description A database is down */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_config: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthorizationConfigDto"]; + }; + }; + }; + }; + AuthController_token: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IssueTokenDto"]; + "application/x-www-form-urlencoded": components["schemas"]["IssueTokenDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenResponseDto"]; + }; + }; + /** @description Pireon rejected the grant */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + UsersController_search: { + parameters: { + query?: { + /** @description Name or email */ + search?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + UsersController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + PireonWebhookController_receive: { + parameters: { + query?: never; + header: { + "x-webhook-signature": string; + /** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */ + "X-Webhook-Signature": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Processed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ProjectsController_mine: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateProjectDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_remove: { + parameters: { + query: { + /** @description The project key, to confirm the deletion */ + confirm: string; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateProjectDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_updateSettings: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectSettingsBodyDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_board: { + parameters: { + query?: { + /** @description Only this release */ + releaseId?: string; + /** @description Only this person (Pireon user id) */ + assigneeId?: string; + /** @description An area id */ + area?: string; + perColumn?: number; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BoardColumnDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_stats: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectStatsDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_analytics: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectAnalyticsDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_activity: { + parameters: { + query?: { + /** @description Only this ticket */ + issueId?: string; + limit?: number; + /** @description Cursor: entries older than this date */ + before?: string; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ActivityDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_addMember: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddMemberDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_removeMember: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ProjectsController_changeMemberRole: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangeMemberRoleDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_mine: { + parameters: { + query?: { + limit?: number; + offset?: number; + /** @description true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA) */ + assignedToMe?: boolean; + /** @description Status ids. Repeat the parameter or separate with commas */ + status?: string[]; + /** @description Whatever each project calls its statuses */ + category?: components["schemas"]["StatusCategory"][]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssuePageDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_search: { + parameters: { + query?: { + limit?: number; + offset?: number; + /** @description Status ids. Repeat the parameter or separate with commas */ + status?: string[]; + category?: components["schemas"]["StatusCategory"][]; + /** @description Ticket type ids */ + type?: string[]; + /** @description Area ids */ + area?: string[]; + priority?: components["schemas"]["IssuePriority"][]; + /** @description Pireon user id */ + assigneeId?: string; + /** @description Only tickets without assignee */ + unassigned?: boolean; + releaseId?: string; + label?: string[]; + /** @description Matches key, title or description */ + search?: string; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssuePageDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateIssueDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssueDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateIssueDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_changeStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangeStatusDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_assign: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssignIssueDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_setRelease: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetIssueReleaseDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_link: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LinkIssueDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_unlink: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + type: components["schemas"]["IssueLinkType"]; + /** @description Other ticket: id or key */ + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_listComments: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommentPageDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_addComment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommentBodyDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_history: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ActivityDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + AttachmentsController_attach: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["AttachFileDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + AttachmentsController_download: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + attachmentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The file */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + AttachmentsController_remove: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + attachmentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + CommentsController_delete: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + CommentsController_edit: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommentBodyDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_upcoming: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_list: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateReleaseDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_update: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateReleaseDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_progress: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseProgressDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_burnup: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleaseBurnupDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_issuesOf: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssuePageDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_publish: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PublishReleaseDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ReleasesController_archive: { + parameters: { + query?: never; + header?: never; + path: { + releaseId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + ExploreController_publicProjects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectDto"][]; + }; + }; }; - }; }; - }; } diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index 10fb10f..ffdcdbc 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 81246f6..6e0f31c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,11 @@ services: # document is still served at /api/docs-json. GRAPHQL_SCHEMA_FILE: '' OPENAPI_FILE: '' + ATTACHMENTS_DIR: /data/attachments + ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25} + volumes: + # Files attached to tickets: lose this volume and the tickets point at nothing. + - attachments-data:/data/attachments depends_on: postgres: { condition: service_healthy } mongo: { condition: service_healthy } @@ -125,6 +130,7 @@ services: retries: 10 volumes: + attachments-data: postgres-data: mongo-data: redis-data: diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index bd5f141..efd0f30 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -14,6 +14,7 @@ | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | +| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | diff --git a/docs/03-api.md b/docs/03-api.md index bef190c..1a1db5d 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -144,18 +144,22 @@ stateDiagram-v2 - **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`. - **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. - **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. +- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por + ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin. + Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no + existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. ### Eventos de dominio Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`, `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). -| Agregado | Eventos | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | -| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` | -| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | -| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | +| Agregado | Eventos | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | +| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueDeletedEvent` | +| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | +| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | `IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. @@ -209,7 +213,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Ámbito | Mutations | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | -| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `removeAttachment` (subir: por REST) | | Comentarios | `addComment`, `editComment`, `deleteComment` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | @@ -413,10 +417,16 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el +ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto +plano, y todo con `X-Content-Type-Options: nosniff` y una CSP `sandbox`: un HTML o un SVG subido se descarga, +nunca se ejecuta en el origen de la API. + Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para diff --git a/docs/06-desarrollo.md b/docs/06-desarrollo.md index c4544c5..9f99a79 100644 --- a/docs/06-desarrollo.md +++ b/docs/06-desarrollo.md @@ -32,6 +32,7 @@ la web y la API comparten origen igual que en Docker. | `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado | | `npx nx run-many -t build` | Builds de la API y la web | | `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` | +| `npx nx run api:contracts` | Escribe `schema.gql` y `openapi.json` sin arrancar la API ni bases de datos | | `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) | | `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` | | `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) | @@ -70,7 +71,8 @@ Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siem por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`. - Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura). 3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service. -4. Arrancar la API para regenerar `apps/api/schema.gql`. +4. Regenerar `apps/api/schema.gql` y `apps/api/openapi.json`: arrancando la API, o sin bases de datos con + `npx nx run api:contracts`. ### Web diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 8f39cce..05aef6d 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -93,13 +93,16 @@ donde los lee Compose. | `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | | `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` | | `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) | +| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` | +| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). ## 7.3 Escalado - **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás - del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. + del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se + guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen. - **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados. - **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola** instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se @@ -111,7 +114,8 @@ La web no tiene configuración de entorno: llama a la API en su mismo origen (`/ - Registrar el cliente OAuth y las redirect URIs de producción en Pireon. - Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS). - Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno. -- Copias de seguridad de PostgreSQL: es la fuente de verdad. +- Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los + eventos dicen qué archivos hay, pero los bytes solo están ahí. ## 7.5 Reconstruir el read model diff --git a/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts b/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts new file mode 100644 index 0000000..620277d --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { BusinessRuleError } from '@task-stream/api/shared/domain'; +import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage'; +import { AttachFileCommand } from './attach-file.command'; +import { CommandResult, ack } from './command-result'; +import { WriteAccess } from './write-access'; + +/** + * Stores the bytes first and records them on the ticket after: a file with no + * record is cleaned up here, never a record with no file. + */ +@CommandHandler(AttachFileCommand) +export class AttachFileCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly storage: AttachmentStorage, + ) {} + + async execute(command: AttachFileCommand): Promise { + const { issue } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + if (command.commentId) { + const comment = await this.access.comment(command.commentId); + if (comment.issueId !== issue.id) { + throw new BusinessRuleError( + `That comment belongs to another ticket, not ${issue.key}`, + ); + } + } + + const id = randomUUID(); + const key = attachmentKey(issue.projectId, issue.id, id); + issue.attach(command.actorId, { + id, + fileName: command.file.fileName, + mimeType: command.file.mimeType, + size: command.file.content.length, + uploadedBy: command.actorId, + uploadedAt: new Date().toISOString(), + commentId: command.commentId, + }); + + await this.storage.put(key, command.file.content); + try { + await issue.commitAndWait(); + } catch (error) { + await this.storage.remove(key); + throw error; + } + return ack(issue); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/attach-file.command.ts b/libs/api/tracker/application/src/lib/commands/attach-file.command.ts new file mode 100644 index 0000000..97ab9a8 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/attach-file.command.ts @@ -0,0 +1,20 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +export interface IncomingFile { + fileName: string; + mimeType: string; + content: Buffer; +} + +export class AttachFileCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly file: IncomingFile, + /** Places it under one of the ticket's comments. */ + public readonly commentId: string | null = null, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 21997fc..a94ebc9 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -25,6 +25,8 @@ import { PurgeCommentCommandHandler } from './purge-comment.command-handler'; import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler'; import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler'; import { UnassignIssueCommandHandler } from './unassign-issue.command-handler'; +import { AttachFileCommandHandler } from './attach-file.command-handler'; +import { DetachFileCommandHandler } from './detach-file.command-handler'; export const COMMAND_HANDLERS = [ CreateProjectCommandHandler, @@ -55,4 +57,6 @@ export const COMMAND_HANDLERS = [ DropLinksToIssueCommandHandler, DetachIssueFromReleaseCommandHandler, UnassignIssueCommandHandler, + AttachFileCommandHandler, + DetachFileCommandHandler, ]; diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index b2b9268..39a1796 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -12,6 +12,7 @@ import { } from '@task-stream/api/shared/domain'; import { Comment, + Issue, IssueStatus, Project, IssueType, @@ -20,6 +21,7 @@ import { } from '@task-stream/api/tracker/domain'; import { FakeUserDirectory, + InMemoryAttachmentStorage, InMemoryEventStore, InMemoryEventStorePublisher, InMemoryIssueNumbers, @@ -32,9 +34,12 @@ import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { ProjectKeysRepository } from '../ports/project-keys.repository'; import { ReleaseNamesRepository } from '../ports/release-names.repository'; import { UserDirectory } from '../ports/user-directory'; +import { AttachmentStorage } from '../ports/attachment-storage'; import { AddCommentCommand } from './add-comment.command'; import { AddProjectMemberCommand } from './add-project-member.command'; import { AssignIssueCommand } from './assign-issue.command'; +import { AttachFileCommand } from './attach-file.command'; +import { DetachFileCommand } from './detach-file.command'; import { ChangeIssueStatusCommand } from './change-issue-status.command'; import { COMMAND_HANDLERS } from './command-handlers'; import { CommandResult } from './command-result'; @@ -48,6 +53,7 @@ import { WriteAccess } from './write-access'; async function setup() { const store = new InMemoryEventStore(); + const files = new InMemoryAttachmentStorage(); const users = new FakeUserDirectory() .add('owner') .add('dev') @@ -62,6 +68,7 @@ async function setup() { WriteAccess, AggregateRehydrator, { provide: EventStore, useValue: store }, + { provide: AttachmentStorage, useValue: files }, { provide: UserDirectory, useValue: users }, { provide: ProjectKeysRepository, useClass: InMemoryProjectKeys }, { provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers }, @@ -78,7 +85,12 @@ async function setup() { const commandBus = moduleRef.get(CommandBus); const run = (command: object): Promise => commandBus.execute(command as never); - return { store, run, rehydrator: moduleRef.get(AggregateRehydrator) }; + return { + store, + files, + run, + rehydrator: moduleRef.get(AggregateRehydrator), + }; } async function projectWithTeam(run: (c: object) => Promise) { @@ -178,6 +190,74 @@ describe('tracker commands (event sourced)', () => { ).rejects.toThrow(BusinessRuleError); }); + describe('attachments', () => { + const png = { + fileName: 'captura.png', + mimeType: 'image/png', + content: Buffer.from('fake png'), + }; + + it('stores the file and records it on the ticket', async () => { + const { run, files, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), + ); + + await run(new AttachFileCommand('qa', issue.id, png)); + + const rebuilt = await rehydrator.rehydrate(issue.id, Issue); + expect(rebuilt.attachments).toHaveLength(1); + expect(rebuilt.attachments[0]).toMatchObject({ + fileName: 'captura.png', + size: 8, + uploadedBy: 'qa', + }); + expect([...files.files.keys()]).toEqual([ + `${projectId}/${issue.id}/${rebuilt.attachments[0].id}`, + ]); + }); + + it('keeps no file when the ticket cannot take it', async () => { + const { run, files } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), + ); + + await expect( + run(new AttachFileCommand('pm', issue.id, png)), + ).rejects.toThrow(ForbiddenError); + await expect( + run( + new AttachFileCommand('qa', issue.id, { + ...png, + content: Buffer.alloc(0), + }), + ), + ).rejects.toThrow(); + expect(files.files.size).toBe(0); + }); + + it('lets the uploader or an admin remove it, and deletes the bytes', async () => { + const { run, files, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), + ); + await run(new AttachFileCommand('qa', issue.id, png)); + const [attachment] = (await rehydrator.rehydrate(issue.id, Issue)) + .attachments; + + await expect( + run(new DetachFileCommand('dev', issue.id, attachment.id)), + ).rejects.toThrow(ForbiddenError); + await run(new DetachFileCommand('owner', issue.id, attachment.id)); + + expect(files.files.size).toBe(0); + }); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/detach-file.command-handler.ts b/libs/api/tracker/application/src/lib/commands/detach-file.command-handler.ts new file mode 100644 index 0000000..0a29e3e --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/detach-file.command-handler.ts @@ -0,0 +1,35 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ProjectRole } from '@task-stream/api/tracker/domain'; +import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage'; +import { CommandResult, ack } from './command-result'; +import { DetachFileCommand } from './detach-file.command'; +import { WriteAccess } from './write-access'; + +/** Removes the record first; the bytes go once nothing points at them. */ +@CommandHandler(DetachFileCommand) +export class DetachFileCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly storage: AttachmentStorage, + ) {} + + async execute(command: DetachFileCommand): Promise { + const { issue, project } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + const role = project.roleOf(command.actorId); + issue.detach( + command.actorId, + command.attachmentId, + role === ProjectRole.OWNER || role === ProjectRole.ADMIN, + ); + await issue.commitAndWait(); + await this.storage.remove( + attachmentKey(issue.projectId, issue.id, command.attachmentId), + ); + return ack(issue); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/detach-file.command.ts b/libs/api/tracker/application/src/lib/commands/detach-file.command.ts new file mode 100644 index 0000000..cc99edc --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/detach-file.command.ts @@ -0,0 +1,12 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +export class DetachFileCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly attachmentId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index c3fe06f..94c5375 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -26,3 +26,5 @@ export * from './purge-comment.command'; export * from './drop-links-to-issue.command'; export * from './detach-issue-from-release.command'; export * from './unassign-issue.command'; +export * from './attach-file.command'; +export * from './detach-file.command'; diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index 2e08d63..3f4e4b5 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -3,6 +3,8 @@ import { CommentDeletedEvent, CommentEditedEvent, IssueAssignedEvent, + IssueAttachmentAddedEvent, + IssueAttachmentRemovedEvent, IssueCreatedEvent, IssueLinkedEvent, IssueLinkType, @@ -135,6 +137,11 @@ export function describeEvent( : `${actor} sacó ${issueKey} de su release`; } + if (event instanceof IssueAttachmentAddedEvent) + return `${actor} adjuntó ${event.attachment.fileName} a ${issueKey}`; + if (event instanceof IssueAttachmentRemovedEvent) + return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`; + if (event instanceof CommentAddedEvent) return `${actor} comentó en ${issueKey}`; if (event instanceof CommentEditedEvent) diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index ebab9db..6fa3499 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -5,6 +5,8 @@ import { CommentDeletedEvent, CommentEditedEvent, IssueAssignedEvent, + IssueAttachmentAddedEvent, + IssueAttachmentRemovedEvent, IssueCreatedEvent, IssueLinkedEvent, IssueReleaseChangedEvent, @@ -51,6 +53,8 @@ import { versionOf } from './read-model-notifier'; IssueLinkedEvent, IssueUnlinkedEvent, IssueReleaseChangedEvent, + IssueAttachmentAddedEvent, + IssueAttachmentRemovedEvent, CommentAddedEvent, CommentEditedEvent, CommentDeletedEvent, diff --git a/libs/api/tracker/application/src/lib/event-handlers/attachment-cleanup.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/attachment-cleanup.event-handler.ts new file mode 100644 index 0000000..fb02575 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/attachment-cleanup.event-handler.ts @@ -0,0 +1,20 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueDeletedEvent } from '@task-stream/api/tracker/domain'; +import { + AttachmentStorage, + issueAttachmentsPrefix, +} from '../ports/attachment-storage'; + +/** A deleted ticket takes its files with it. */ +@EventsHandler(IssueDeletedEvent) +export class AttachmentCleanupEventHandler + implements IEventHandler +{ + constructor(private readonly storage: AttachmentStorage) {} + + async handle(event: IssueDeletedEvent) { + await this.storage.removePrefix( + issueAttachmentsPrefix(event.projectId, event.aggregateId), + ); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 8627447..66d558f 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -24,6 +24,9 @@ import { ProjectSettingsChangedEventHandler } from './project-settings-changed.e import { ProjectDeletedEventHandler } from './project-deleted.event-handler'; import { IssueDeletedEventHandler } from './issue-deleted.event-handler'; import { ReleaseDeletedEventHandler } from './release-deleted.event-handler'; +import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler'; +import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler'; +import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ export const EVENT_HANDLERS = [ @@ -52,6 +55,9 @@ export const EVENT_HANDLERS = [ ReleaseCreatedEventHandler, ReleaseShippedEventHandler, ReleaseUpdatedEventHandler, + IssueAttachmentAddedEventHandler, + IssueAttachmentRemovedEventHandler, + AttachmentCleanupEventHandler, ]; export { ReadModelNotifier }; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-attachment-added.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-attachment-added.event-handler.ts new file mode 100644 index 0000000..3386e0f --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-attachment-added.event-handler.ts @@ -0,0 +1,31 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueAttachmentAddedEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueAttachmentAddedEvent) +export class IssueAttachmentAddedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly users: UserDirectory, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueAttachmentAddedEvent) { + const { attachment } = event; + const users = await this.users.refs([attachment.uploadedBy]); + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.upsertMaterializedIssueRepository.addAttachment( + event.aggregateId, + { ...attachment, uploadedBy: users.of(attachment.uploadedBy) }, + ); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-attachment-removed.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-attachment-removed.event-handler.ts new file mode 100644 index 0000000..f0772df --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-attachment-removed.event-handler.ts @@ -0,0 +1,27 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueAttachmentRemovedEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueAttachmentRemovedEvent) +export class IssueAttachmentRemovedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueAttachmentRemovedEvent) { + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.upsertMaterializedIssueRepository.removeAttachment( + event.aggregateId, + event.attachmentId, + ); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index 0b48e74..5112959 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -7,6 +7,11 @@ import { IssueReadModel, } from '@task-stream/api/tracker/domain'; import { AssignIssueCommand } from './commands/assign-issue.command'; +import { + AttachFileCommand, + IncomingFile, +} from './commands/attach-file.command'; +import { DetachFileCommand } from './commands/detach-file.command'; import { ChangeIssueStatusCommand } from './commands/change-issue-status.command'; import { CommandResult } from './commands/command-result'; import { CreateIssueCommand } from './commands/create-issue.command'; @@ -16,6 +21,10 @@ import { UnlinkIssuesCommand } from './commands/unlink-issues.command'; import { UpdateIssueCommand } from './commands/update-issue.command'; import { IssueFilter, Page } from './ports/find-issues.repository'; import { GetActivityQuery } from './queries/get-activity.query'; +import { + AttachmentDownload, + GetAttachmentQuery, +} from './queries/get-attachment.query'; import { GetIssueQuery } from './queries/get-issue.query'; import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query'; import { SearchIssuesQuery } from './queries/search-issues.query'; @@ -126,6 +135,39 @@ export class IssuesService { ); } + /** Attaches a file to the ticket, or to one of its comments. */ + attach( + actorId: string, + issueId: string, + file: IncomingFile, + commentId: string | null = null, + ): Promise { + return this.commandBus.execute( + new AttachFileCommand(actorId, issueId, file, commentId), + ); + } + + detach( + actorId: string, + issueId: string, + attachmentId: string, + ): Promise { + return this.commandBus.execute( + new DetachFileCommand(actorId, issueId, attachmentId), + ); + } + + /** A ticket's file, with its bytes, for whoever can read the ticket. */ + download( + viewerId: string | null, + issueId: string, + attachmentId: string, + ): Promise { + return this.queryBus.execute( + new GetAttachmentQuery(viewerId, issueId, attachmentId), + ); + } + findOne(viewerId: string | null, idOrKey: string): Promise { return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey)); } diff --git a/libs/api/tracker/application/src/lib/ports/attachment-storage.ts b/libs/api/tracker/application/src/lib/ports/attachment-storage.ts new file mode 100644 index 0000000..d5656f6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/attachment-storage.ts @@ -0,0 +1,24 @@ +import type { Readable } from 'node:stream'; + +/** Where the bytes of attached files live (the tickets only record them). */ +export abstract class AttachmentStorage { + abstract put(key: string, content: Buffer): Promise; + /** Fails with NotFoundError when there is nothing at `key`. */ + abstract read(key: string): Promise; + /** Idempotent: removing what is not there is fine. */ + abstract remove(key: string): Promise; + /** Everything under `prefix` (a ticket's files, when it is deleted). */ + abstract removePrefix(prefix: string): Promise; +} + +/** One folder per project and ticket, so a ticket's files go together. */ +export const attachmentKey = ( + projectId: string, + issueId: string, + attachmentId: string, +): string => `${projectId}/${issueId}/${attachmentId}`; + +export const issueAttachmentsPrefix = ( + projectId: string, + issueId: string, +): string => `${projectId}/${issueId}/`; diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index 07bb9d3..d9bfbd8 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -17,3 +17,4 @@ export * from './release-issues.repository'; export * from './upsert-materialized-timeline.repository'; export * from './find-timeline.repository'; export * from './tracker-streams.repository'; +export * from './attachment-storage'; diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts index 56c3f4e..3982cd4 100644 --- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts @@ -1,4 +1,5 @@ import { + AttachmentReadModel, IssueLinkReadModel, IssueReadModel, StatusCategory, @@ -8,7 +9,7 @@ export abstract class UpsertMaterializedIssueRepository { /** Applied only if `version` is newer than the stored one (idempotent). */ abstract upsert( issue: Pick & - Partial>, + Partial>, ): Promise; /** Adds a relation to an issue (idempotent). */ abstract addLink(issueId: string, link: IssueLinkReadModel): Promise; @@ -16,6 +17,15 @@ export abstract class UpsertMaterializedIssueRepository { issueId: string, link: Omit, ): Promise; + /** Adds a file to an issue (idempotent). */ + abstract addAttachment( + issueId: string, + attachment: AttachmentReadModel, + ): Promise; + abstract removeAttachment( + issueId: string, + attachmentId: string, + ): Promise; abstract setCommentCount( issueId: string, commentCount: number, diff --git a/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts new file mode 100644 index 0000000..e85f88b --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts @@ -0,0 +1,39 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { AttachmentDownload, GetAttachmentQuery } from './get-attachment.query'; +import { ReadAccess } from './read-access'; + +@QueryHandler(GetAttachmentQuery) +export class GetAttachmentQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly storage: AttachmentStorage, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetAttachmentQuery): Promise { + const issue = this.access.assertFound( + await this.findIssues.findById(query.issueId), + 'Attachment', + query.attachmentId, + ); + await this.access.assertCanView(issue.projectId, query.viewerId, { + entity: 'Attachment', + ref: query.attachmentId, + }); + const attachment = (issue.attachments ?? []).find( + (a) => a.id === query.attachmentId, + ); + if (!attachment) throw new NotFoundError('Attachment', query.attachmentId); + return { + attachment, + content: await this.storage.read( + attachmentKey(issue.projectId, issue.id, attachment.id), + ), + }; + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-attachment.query.ts b/libs/api/tracker/application/src/lib/queries/get-attachment.query.ts new file mode 100644 index 0000000..f18988b --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-attachment.query.ts @@ -0,0 +1,20 @@ +import { Query } from '@nestjs/cqrs'; +import { AttachmentReadModel } from '@task-stream/api/tracker/domain'; +import type { Readable } from 'node:stream'; + +export interface AttachmentDownload { + attachment: AttachmentReadModel; + content: Readable; +} + +/** A ticket's file, for whoever can read the ticket. */ +export class GetAttachmentQuery extends Query { + constructor( + /** Null: anonymous (public projects only). */ + public readonly viewerId: string | null, + public readonly issueId: string, + public readonly attachmentId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index 776d13c..b01d451 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -16,3 +16,4 @@ export * from './get-upcoming-releases.query'; export * from './search-issues.query'; export * from './search-my-issues.query'; export * from './get-public-projects.query'; +export * from './get-attachment.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index b83871e..9986736 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -1,3 +1,4 @@ +import { GetAttachmentQueryHandler } from './get-attachment.query-handler'; import { GetPublicProjectsQueryHandler } from './get-public-projects.query-handler'; import { CanViewProjectQueryHandler } from './can-view-project.query-handler'; import { GetActivityQueryHandler } from './get-activity.query-handler'; @@ -36,4 +37,5 @@ export const QUERY_HANDLERS = [ GetUpcomingReleasesQueryHandler, SearchIssuesQueryHandler, SearchMyIssuesQueryHandler, + GetAttachmentQueryHandler, ]; diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts index c18b77e..e14a2d2 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts @@ -23,6 +23,7 @@ import { import { lastValueFrom, of, toArray } from 'rxjs'; import { FakeUserDirectory, + InMemoryAttachmentStorage, InMemoryEventStore, InMemoryEventStorePublisher, InMemoryIssueNumbers, @@ -51,12 +52,14 @@ import { ReleaseIssuesRepository } from '../ports/release-issues.repository'; import { ReleaseNamesRepository } from '../ports/release-names.repository'; import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; import { UserDirectory } from '../ports/user-directory'; +import { AttachmentStorage } from '../ports/attachment-storage'; import { ProjectDeletionSaga } from './project-deletion.saga'; import { ReleaseArchivedSaga } from './release-archived.saga'; import { UserDeactivatedSaga } from './user-deactivated.saga'; async function setup() { const store = new InMemoryEventStore(); + const files = new InMemoryAttachmentStorage(); const users = new FakeUserDirectory().add('owner').add('dev').add('qa'); // Read model double for the user saga: open tickets are tracked by the test. const openAssigned = new Map(); @@ -71,6 +74,7 @@ async function setup() { WriteAccess, AggregateRehydrator, { provide: EventStore, useValue: store }, + { provide: AttachmentStorage, useValue: files }, { provide: UserDirectory, useValue: users }, { provide: ProjectKeysRepository, useClass: InMemoryProjectKeys }, { provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers }, diff --git a/libs/api/tracker/application/src/testing/in-memory.ts b/libs/api/tracker/application/src/testing/in-memory.ts index 6194e3d..9a9c338 100644 --- a/libs/api/tracker/application/src/testing/in-memory.ts +++ b/libs/api/tracker/application/src/testing/in-memory.ts @@ -7,6 +7,9 @@ import { SerializableEvent, VersionedAggregateRoot, } from '@task-stream/api/shared/domain'; +import { Readable } from 'node:stream'; +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { AttachmentStorage } from '../lib/ports/attachment-storage'; import { IssueNumbersRepository } from '../lib/ports/issue-numbers.repository'; import { ProjectKeysRepository } from '../lib/ports/project-keys.repository'; import { ReleaseIssuesRepository } from '../lib/ports/release-issues.repository'; @@ -244,3 +247,28 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository { ); } } + +/** Attachment storage double: files in a map, keyed like on disk. */ +export class InMemoryAttachmentStorage extends AttachmentStorage { + readonly files = new Map(); + + async put(key: string, content: Buffer) { + this.files.set(key, content); + } + + async read(key: string) { + const content = this.files.get(key); + if (!content) throw new NotFoundError('Attachment', key); + return Readable.from(content); + } + + async remove(key: string) { + this.files.delete(key); + } + + async removePrefix(prefix: string) { + for (const key of [...this.files.keys()]) { + if (key.startsWith(prefix)) this.files.delete(key); + } + } +} diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 51d3530..9ed5c2d 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/project/project'; export * from './lib/project/project-role'; export * from './lib/issue/issue'; export * from './lib/issue/issue.enums'; +export * from './lib/issue/issue-attachment'; export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 54b5afb..0b11287 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -22,3 +22,5 @@ export * from './release-archived.event'; export * from './project-deleted.event'; export * from './issue-deleted.event'; export * from './release-deleted.event'; +export * from './issue-attachment-added.event'; +export * from './issue-attachment-removed.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-attachment-added.event.ts b/libs/api/tracker/domain/src/lib/events/issue-attachment-added.event.ts new file mode 100644 index 0000000..262e85f --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-attachment-added.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import type { IssueAttachment } from '../issue/issue-attachment'; +import { TrackerEvent } from './tracker.event'; + +/** A file was attached to a ticket (or to one of its comments). */ +@AutowiredEvent +export class IssueAttachmentAddedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly attachment: IssueAttachment, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-attachment-removed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-attachment-removed.event.ts new file mode 100644 index 0000000..6c7d4c3 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-attachment-removed.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A file was removed from a ticket. */ +@AutowiredEvent +export class IssueAttachmentRemovedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly attachmentId: string, + readonly fileName: string, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue-attachment.ts b/libs/api/tracker/domain/src/lib/issue/issue-attachment.ts new file mode 100644 index 0000000..978763a --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/issue-attachment.ts @@ -0,0 +1,35 @@ +import { assertThat } from '@task-stream/api/shared/domain'; + +/** + * A file attached to a ticket — a screenshot, a log, a short video. The bytes + * live in the attachment storage; the ticket only records that they exist. + * `commentId` places it under one of the ticket's comments. + */ +export interface IssueAttachment { + id: string; + fileName: string; + mimeType: string; + /** Bytes. */ + size: number; + uploadedBy: string; + uploadedAt: string; + commentId: string | null; +} + +/** A ticket keeps at most this many files. */ +export const MAX_ATTACHMENTS_PER_ISSUE = 50; + +/** Trims the name and keeps only its last path segment. */ +export function normalizeAttachment( + attachment: IssueAttachment, +): IssueAttachment { + const fileName = + attachment.fileName.split(/[\\/]/).pop()?.trim().slice(0, 200) ?? ''; + assertThat(fileName.length > 0, 'An attachment needs a file name'); + assertThat(attachment.size > 0, 'An attachment cannot be empty'); + return { + ...attachment, + fileName, + mimeType: attachment.mimeType || 'application/octet-stream', + }; +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index cb22596..97ea0b9 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -1,5 +1,6 @@ import { BusinessRuleError, + ForbiddenError, ValidationError, } from '@task-stream/api/shared/domain'; import { IssueCreatedEvent } from '../events/issue-created.event'; @@ -154,4 +155,55 @@ describe('Issue', () => { }); expect(rebuilt.getUncommittedEvents()).toHaveLength(0); }); + + describe('attachments', () => { + const file = (id: string, uploadedBy = 'u-qa') => ({ + id, + fileName: 'C:\\Users\\qa\\captura login.png', + mimeType: 'image/png', + size: 2048, + uploadedBy, + uploadedAt: '2026-10-01T10:00:00.000Z', + commentId: null, + }); + + it('records a file with its name cleaned up, once', () => { + const issue = newIssue(); + issue.attach('u-qa', file('a-1')); + issue.attach('u-qa', file('a-1')); + + expect(issue.attachments).toHaveLength(1); + expect(issue.attachments[0].fileName).toBe('captura login.png'); + }); + + it('lets the uploader or a manager remove it, nobody else', () => { + const issue = newIssue(); + issue.attach('u-qa', file('a-1')); + + expect(() => issue.detach('u-dev', 'a-1', false)).toThrow(ForbiddenError); + issue.detach('u-dev', 'a-1', true); + + expect(issue.attachments).toEqual([]); + expect(() => issue.detach('u-qa', 'a-1', true)).toThrow( + BusinessRuleError, + ); + }); + + it('rejects empty files', () => { + expect(() => + newIssue().attach('u-qa', { ...file('a-1'), size: 0 }), + ).toThrow(ValidationError); + }); + + it('survives a replay', () => { + const issue = newIssue(); + issue.attach('u-qa', file('a-1')); + issue.attach('u-qa', file('a-2')); + issue.detach('u-qa', 'a-1', false); + + const { rebuilt } = replay(issue, Issue); + + expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']); + }); + }); }); diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 4b70320..75de394 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -1,9 +1,12 @@ import { BusinessRuleError, + ForbiddenError, VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; import { IssueAssignedEvent } from '../events/issue-assigned.event'; +import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event'; +import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event'; import { IssueCreatedEvent } from '../events/issue-created.event'; import { IssueDeletedEvent } from '../events/issue-deleted.event'; import { IssueLinkedEvent } from '../events/issue-linked.event'; @@ -12,6 +15,11 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; import { IssueUpdatedEvent } from '../events/issue-updated.event'; import { ProjectWorkflow } from '../project/project-settings'; +import { + IssueAttachment, + MAX_ATTACHMENTS_PER_ISSUE, + normalizeAttachment, +} from './issue-attachment'; import { IssueLinkType, IssuePriority } from './issue.enums'; export interface IssueLink { @@ -82,6 +90,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public storyPoints: number | null = null; public dueDate: string | null = null; public links: IssueLink[] = []; + public attachments: IssueAttachment[] = []; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -216,6 +225,43 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** Records a file stored for this ticket (or one of its comments). */ + attach(actorId: string, attachment: IssueAttachment): void { + const next = normalizeAttachment(attachment); + if (this.attachments.some((a) => a.id === next.id)) return; + if (this.attachments.length >= MAX_ATTACHMENTS_PER_ISSUE) { + throw new BusinessRuleError( + `${this.key} already has ${MAX_ATTACHMENTS_PER_ISSUE} attachments; remove one first`, + ); + } + this.apply( + new IssueAttachmentAddedEvent(actorId, this.id, this.projectId, next), + ); + } + + /** + * Removes a file. Whoever uploaded it may, and so may whoever manages the + * project (`canManage`). + */ + detach(actorId: string, attachmentId: string, canManage: boolean): void { + const attachment = this.attachments.find((a) => a.id === attachmentId); + if (!attachment) throw new BusinessRuleError('Attachment not found'); + if (attachment.uploadedBy !== actorId && !canManage) { + throw new ForbiddenError( + 'Only whoever uploaded a file, or a project admin, can remove it', + ); + } + this.apply( + new IssueAttachmentRemovedEvent( + actorId, + this.id, + this.projectId, + attachment.id, + attachment.fileName, + ), + ); + } + /** Deletes the ticket (its project was deleted). Idempotent. */ delete(actorId: string): void { if (this.deleted) return; @@ -276,6 +322,18 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.updatedAt = event.occurredAt; } + onIssueAttachmentAddedEvent(event: IssueAttachmentAddedEvent): void { + this.attachments = [...this.attachments, { ...event.attachment }]; + this.updatedAt = event.occurredAt; + } + + onIssueAttachmentRemovedEvent(event: IssueAttachmentRemovedEvent): void { + this.attachments = this.attachments.filter( + (a) => a.id !== event.attachmentId, + ); + this.updatedAt = event.occurredAt; + } + onIssueDeletedEvent(): void { this.deleted = true; } diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index cc60d6d..e289dc1 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -12,6 +12,17 @@ export class IssueLinkReadModel { issueKey!: string; } +export class AttachmentReadModel { + id!: string; + fileName!: string; + mimeType!: string; + size!: number; + uploadedBy!: UserRefReadModel; + uploadedAt!: string; + /** Shown under this comment when set. */ + commentId!: string | null; +} + export class IssueReadModel { id!: string; projectId!: string; @@ -35,6 +46,7 @@ export class IssueReadModel { storyPoints!: number | null; dueDate!: string | null; links!: IssueLinkReadModel[]; + attachments!: AttachmentReadModel[]; commentCount!: number; createdAt!: string; updatedAt!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index a3b38ae..04e775c 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -69,6 +69,18 @@ export const IssueSchema = new Schema( release: { _id: false, id: String, name: String }, outwardLinks: [link], inwardLinks: [link], + attachments: [ + { + _id: false, + id: String, + fileName: String, + mimeType: String, + size: Number, + uploadedBy: userRef, + uploadedAt: String, + commentId: String, + }, + ], labels: [String], version: Number, }, @@ -137,5 +149,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { id: _id, ...rest, links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])], + // Tickets projected before attachments existed have none. + attachments: rest.attachments ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index a40584f..634316d 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -10,6 +10,7 @@ import { } from '@task-stream/api/tracker/application'; import { ActivityReadModel, + AttachmentReadModel, CommentReadModel, IssueLinkReadModel, IssueReadModel, @@ -99,14 +100,29 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs version, ...fields }: Pick & - Partial>) { + Partial>) { await upsertIfNewer(this.issues, id, version, fields, { outwardLinks: [], inwardLinks: [], + attachments: [], commentCount: 0, }); } + async addAttachment(issueId: string, attachment: AttachmentReadModel) { + await this.issues.updateOne( + { _id: issueId, 'attachments.id': { $ne: attachment.id } }, + { $push: { attachments: attachment } }, + ); + } + + async removeAttachment(issueId: string, attachmentId: string) { + await this.issues.updateOne( + { _id: issueId }, + { $pull: { attachments: { id: attachmentId } } }, + ); + } + async addLink(issueId: string, link: IssueLinkReadModel) { const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks'; await this.issues.updateOne( diff --git a/libs/api/tracker/infrastructure/src/lib/storage/file-system-attachment.storage.ts b/libs/api/tracker/infrastructure/src/lib/storage/file-system-attachment.storage.ts new file mode 100644 index 0000000..0b9d3a8 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/storage/file-system-attachment.storage.ts @@ -0,0 +1,57 @@ +import { createReadStream } from 'node:fs'; +import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, resolve, sep } from 'node:path'; +import type { Readable } from 'node:stream'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { AttachmentStorage } from '@task-stream/api/tracker/application'; + +/** + * Attached files on disk, under ATTACHMENTS_DIR (a volume in Docker). Keys + * come from ids this API generated, but are still checked to stay inside the + * folder: a key is never trusted to be a path. + */ +@Injectable() +export class FileSystemAttachmentStorage extends AttachmentStorage { + private readonly root: string; + + constructor(config: ConfigService) { + super(); + this.root = resolve( + config.get('ATTACHMENTS_DIR') || 'data/attachments', + ); + } + + async put(key: string, content: Buffer): Promise { + const path = this.pathOf(key); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, { flag: 'wx' }); + } + + async read(key: string): Promise { + const path = this.pathOf(key); + try { + await stat(path); + } catch { + throw new NotFoundError('Attachment', key.split('/').pop() ?? key); + } + return createReadStream(path); + } + + async remove(key: string): Promise { + await rm(this.pathOf(key), { force: true }); + } + + async removePrefix(prefix: string): Promise { + await rm(this.pathOf(prefix), { recursive: true, force: true }); + } + + private pathOf(key: string): string { + const path = resolve(this.root, key); + if (path !== this.root && !path.startsWith(this.root + sep)) { + throw new Error(`Attachment key escapes the storage folder: ${key}`); + } + return path; + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 5654404..e8461dc 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { MongooseModule } from '@nestjs/mongoose'; import { TypeOrmModule } from '@nestjs/typeorm'; import { + AttachmentStorage, ChangeFeed, FindActivityRepository, FindCommentsRepository, @@ -51,6 +52,7 @@ import { import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository'; import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { RedisChangeFeed } from './realtime/redis-change-feed'; +import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage'; const PORTS = [ // Write side lookups → PostgreSQL (aggregates themselves live in the event store) @@ -96,6 +98,8 @@ const PORTS = [ provide: UpsertMaterializedTimelineRepository, useClass: MongoUpsertMaterializedTimelineRepository, }, + // Attached files → disk (ATTACHMENTS_DIR) + { provide: AttachmentStorage, useClass: FileSystemAttachmentStorage }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, // Identity context (anti-corruption layer) diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 724fda6..df916a0 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -183,6 +183,18 @@ export class IssueResolver { ); } + @Mutation(() => CommandResultGql, { + description: + 'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.', + }) + removeAttachment( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('attachmentId', { type: () => ID }) attachmentId: string, + ) { + return this.issuesService.detach(me.id, issueId, attachmentId); + } + @Mutation(() => CommandResultGql, { description: 'Assign (or unassign with null) a project member', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index a2d2fa5..223be6f 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -91,6 +91,24 @@ export class IssueLinkGql { @Field() issueKey!: string; } +@ObjectType('Attachment', { + description: + 'A file on a ticket. Download it from the REST API: GET /api/issues/{issueId}/attachments/{id}', +}) +export class AttachmentGql { + @Field(() => ID) id!: string; + @Field() fileName!: string; + @Field() mimeType!: string; + @Field(() => Int, { description: 'Bytes' }) size!: number; + @Field(() => UserRefGql) uploadedBy!: UserRefGql; + @Field() uploadedAt!: string; + @Field(() => ID, { + nullable: true, + description: 'The comment it was attached to, if any', + }) + commentId!: string | null; +} + @ObjectType('ReleaseRef') export class ReleaseRefGql { @Field(() => ID) id!: string; @@ -123,6 +141,7 @@ export class IssueGql { @Field(() => Int, { nullable: true }) storyPoints!: number | null; @Field(() => String, { nullable: true }) dueDate!: string | null; @Field(() => [IssueLinkGql]) links!: IssueLinkGql[]; + @Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; @Field(() => Int) commentCount!: number; @Field() createdAt!: string; @Field() updatedAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/attachments.controller.ts b/libs/api/tracker/presenters/src/lib/http/attachments.controller.ts new file mode 100644 index 0000000..6feb9cf --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/attachments.controller.ts @@ -0,0 +1,158 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiProduces, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiOptionalPireonAuth, + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { ValidationError } from '@task-stream/api/shared/domain'; +import { IssuesService } from '@task-stream/api/tracker/application'; +import type { Response } from 'express'; +import { viewerOf } from '../common/viewer'; +import { AttachFileDto } from './dto/requests.dto'; +import { CommandResultDto } from './dto/responses.dto'; +import { Refs } from './refs'; + +const ISSUE = ApiParam({ + name: 'issue', + description: 'Ticket id or key', + example: 'WEB-12', +}); + +/** What multer hands over for one uploaded field. */ +interface UploadedPart { + originalname: string; + mimetype: string; + buffer: Buffer; +} + +/** + * Types a browser may show in place. Anything else — HTML, SVG, scripts — is + * always downloaded: a file someone uploaded must never run in this origin. + */ +const SHOWN_INLINE = + /^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/; + +/** REST adapter for a ticket's files: uploaded here, listed on the ticket. */ +@ApiTags('Issues') +@ApiPireonAuth() +@Controller('issues/:issue/attachments') +export class AttachmentsController { + constructor( + private readonly issues: IssuesService, + private readonly refs: Refs, + ) {} + + @Post() + @ISSUE + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: AttachFileDto }) + @ApiOperation({ + summary: 'Attach a file to a ticket (members)', + 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.', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async attach( + @CurrentUser() me: User, + @Param('issue') issue: string, + @UploadedFile() file: UploadedPart | undefined, + @Body() body: AttachFileDto, + ) { + if (!file) throw new ValidationError('Send the file as `file`'); + return this.issues.attach( + me.id, + await this.refs.issueId(me.id, issue), + { + fileName: Buffer.from(file.originalname, 'latin1').toString('utf8'), + mimeType: file.mimetype, + content: file.buffer, + }, + body.commentId || null, + ); + } + + @Get(':attachmentId') + @ApiOptionalPireonAuth() + @ISSUE + @ApiOperation({ summary: "Download one of a ticket's files" }) + @ApiProduces('application/octet-stream') + @ApiOkResponse({ description: 'The file' }) + @ApiReadErrors() + async download( + @CurrentUser() me: User | undefined, + @Param('issue') issue: string, + @Param('attachmentId') attachmentId: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + const viewer = viewerOf(me); + const { attachment, content } = await this.issues.download( + viewer, + await this.refs.issueId(viewer, issue), + attachmentId, + ); + const inline = SHOWN_INLINE.test(attachment.mimeType); + res.set({ + 'Content-Type': inline ? attachment.mimeType : 'application/octet-stream', + 'Content-Length': String(attachment.size), + 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${asciiName(attachment.fileName)}"; filename*=UTF-8''${encodeURIComponent(attachment.fileName)}`, + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + 'Cache-Control': 'private, max-age=3600', + }); + return new StreamableFile(content); + } + + @Delete(':attachmentId') + @ISSUE + @ApiOperation({ + summary: 'Remove a file (whoever uploaded it, or an owner/admin)', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async remove( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Param('attachmentId') attachmentId: string, + ) { + return this.issues.detach( + me.id, + await this.refs.issueId(me.id, issue), + attachmentId, + ); + } +} + +/** The name with anything outside printable ASCII replaced, for old clients. */ +function asciiName(name: string): string { + return name.replace(/[^\x20-\x7e]|["\\]/g, '_'); +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 9a7b1a7..f9afd1a 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -22,6 +22,7 @@ import { IsInt, IsISO8601, IsOptional, + IsUUID, IsString, IsUrl, Matches, @@ -576,6 +577,20 @@ export class LinkTypeParamDto { type!: IssueLinkType; } +/** Multipart body of an upload; the file itself travels as `file`. */ +export class AttachFileDto { + @ApiProperty({ type: 'string', format: 'binary', description: 'The file' }) + file?: unknown; + + @ApiPropertyOptional({ + format: 'uuid', + description: "One of the ticket's comments, to show the file under it", + }) + @IsOptional() + @IsUUID() + commentId?: string; +} + export class CommentBodyDto { @ApiProperty({ example: 'Reproducido en Safari 17.', maxLength: 20000 }) @IsString() diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 26148d8..bb6e90c 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -106,6 +106,22 @@ export class ReleaseRefDto { @ApiProperty({ example: 'v1.4.0' }) name!: string; } +export class AttachmentDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'captura-login.png' }) fileName!: string; + @ApiProperty({ example: 'image/png' }) mimeType!: string; + @ApiProperty({ description: 'Bytes', example: 48213 }) size!: number; + @ApiProperty({ type: UserRefDto }) uploadedBy!: UserRefDto; + @ApiProperty({ format: 'date-time' }) uploadedAt!: string; + @ApiProperty({ + type: String, + nullable: true, + format: 'uuid', + description: 'The comment it was attached to, if any', + }) + commentId!: string | null; +} + export class IssueDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) projectId!: string; @@ -141,6 +157,7 @@ export class IssueDto { @ApiProperty({ type: String, nullable: true, format: 'date' }) dueDate!: string | null; @ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[]; + @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; @ApiProperty() commentCount!: number; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) updatedAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index a3cd1e2..9caf8cf 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -1,10 +1,13 @@ import { DynamicModule, Module, Type } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MulterModule } from '@nestjs/platform-express'; import './graphql/enums'; import { CommentResolver } from './graphql/comment.resolver'; import { IssueResolver } from './graphql/issue.resolver'; import { ProjectResolver } from './graphql/project.resolver'; import { ReleaseResolver } from './graphql/release.resolver'; import { SubscriptionResolver } from './graphql/subscription.resolver'; +import { AttachmentsController } from './http/attachments.controller'; import { CommentsController } from './http/comments.controller'; import { IssuesController } from './http/issues.controller'; import { ExploreController } from './http/explore.controller'; @@ -18,9 +21,24 @@ import { ReleasesController } from './http/releases.controller'; * application services. */ @Module({ + imports: [ + // Uploads stay in memory until the command stores them; the size limit + // is enforced here, before a byte reaches the application. + MulterModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + limits: { + fileSize: + (config.get('ATTACHMENTS_MAX_MB') ?? 25) * 1024 * 1024, + files: 1, + }, + }), + }), + ], controllers: [ ProjectsController, IssuesController, + AttachmentsController, CommentsController, ReleasesController, ExploreController, @@ -38,6 +56,9 @@ export class TrackerPresentersModule { static withApplication( applicationModule: Type | DynamicModule, ): DynamicModule { - return { module: TrackerPresentersModule, imports: [applicationModule] }; + return { + module: TrackerPresentersModule, + imports: [applicationModule], + }; } } diff --git a/libs/web/shared/ui/src/lib/icons.ts b/libs/web/shared/ui/src/lib/icons.ts index d1e193e..0e0e7f4 100644 --- a/libs/web/shared/ui/src/lib/icons.ts +++ b/libs/web/shared/ui/src/lib/icons.ts @@ -87,6 +87,10 @@ export const APP_ICONS: Record = { '', logout: '', + image: + '', + paperclip: + '', trash: '', edit: '', diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index 84821c5..76e2f5b 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -29,7 +29,21 @@ export const issuePageEvents = eventGroup({ issueId: string; releaseId: string | null; }>(), - commentAddRequested: type<{ issueId: string; body: string }>(), + commentAddRequested: type<{ + issueId: string; + body: string; + /** Uploaded under the new comment once it exists. */ + files?: File[]; + }>(), + attachRequested: type<{ + issueId: string; + files: File[]; + commentId?: string | null; + }>(), + attachmentRemoveRequested: type<{ + issueId: string; + attachmentId: string; + }>(), commentEditRequested: type<{ commentId: string; body: string }>(), commentDeleteRequested: type<{ commentId: string }>(), }, diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index 4bbf21c..3495210 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -4,6 +4,7 @@ import { Events } from '@ngrx/signals/events'; import { AuthFacade } from '@task-stream/web/identity/application'; import { ActivityGateway, + AttachmentsGateway, CommentsGateway, IssuesGateway, Project, @@ -45,7 +46,15 @@ function gateways() { issue: vi.fn(() => of(issue)), changeStatus: vi.fn(() => of(ok)), }, - list: { list: vi.fn(() => of([])) }, + list: { + list: vi.fn(() => of([])), + add: vi.fn(() => of({ ...ok, id: 'c1' })), + }, + attachments: { + upload: vi.fn(() => of(ok)), + download: vi.fn(() => of(new Blob(['x']))), + remove: vi.fn(() => of(ok)), + }, realtime: { issueChanges: () => NEVER, commentChanges: () => NEVER, @@ -80,6 +89,7 @@ function setup() { { provide: ReleasesGateway, useValue: fakes.list }, { provide: ActivityGateway, useValue: fakes.list }, { provide: CommentsGateway, useValue: fakes.list }, + { provide: AttachmentsGateway, useValue: fakes.attachments }, { provide: UsersGateway, useValue: { search: () => of([]) } }, { provide: TrackerRealtimeGateway, useValue: fakes.realtime }, { @@ -222,6 +232,24 @@ describe('IssueFacade', () => { reason: 'Falla en Safari', }); }); + + it('uploads files one by one, and under a new comment when sent with it', () => { + const { issue, fakes } = setup(); + const a = new File(['a'], 'a.png', { type: 'image/png' }); + const b = new File(['b'], 'b.log', { type: 'text/plain' }); + + issue.open('WEB-1'); + issue.attach([a, b]); + issue.addComment('Adjunto la traza', [b]); + issue.removeAttachment('f1'); + + expect(fakes.attachments.upload.mock.calls).toEqual([ + ['i1', a, null], + ['i1', b, null], + ['i1', b, 'c1'], + ]); + expect(fakes.attachments.remove).toHaveBeenCalledWith('i1', 'f1'); + }); }); describe('HomeFacade', () => { diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index af6e32c..cd0384e 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -1,10 +1,13 @@ import { inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; import { + Attachment, + AttachmentsGateway, IssueLink, IssueLinkType, UpdateIssueInput, } from '@task-stream/web/tracker/domain'; +import { Observable } from 'rxjs'; import { issuePageEvents } from '../events/issue.events'; import { IssueStore } from '../stores/issue.store'; @@ -16,6 +19,7 @@ import { IssueStore } from '../stores/issue.store'; export class IssueFacade { private readonly store = inject(IssueStore); private readonly dispatch = injectDispatch(issuePageEvents); + private readonly files = inject(AttachmentsGateway); readonly issue = this.store.issue; readonly comments = this.store.comments; @@ -74,12 +78,33 @@ export class IssueFacade { ); } - addComment(body: string): void { + /** `files` are attached under the new comment. */ + addComment(body: string, files: File[] = []): void { this.withIssue((issueId) => - this.dispatch.commentAddRequested({ issueId, body }), + this.dispatch.commentAddRequested({ issueId, body, files }), ); } + /** Attaches files to the ticket (or under one of its comments). */ + attach(files: File[], commentId: string | null = null): void { + if (!files.length) return; + this.withIssue((issueId) => + this.dispatch.attachRequested({ issueId, files, commentId }), + ); + } + + removeAttachment(attachmentId: string): void { + this.withIssue((issueId) => + this.dispatch.attachmentRemoveRequested({ issueId, attachmentId }), + ); + } + + /** The file's bytes, to show an image or save the file. */ + fileOf(attachment: Attachment): Observable { + const issueId = this.store.issue()?.id ?? ''; + return this.files.download(issueId, attachment); + } + editComment(commentId: string, body: string): void { this.dispatch.commentEditRequested({ commentId, body }); } diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 2b92f40..cbdf751 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -11,6 +11,7 @@ import { AuthFacade } from '@task-stream/web/identity/application'; import { Activity, ActivityGateway, + AttachmentsGateway, Comment, CommentsGateway, Issue, @@ -18,8 +19,11 @@ import { TrackerRealtimeGateway, } from '@task-stream/web/tracker/domain'; import { + concatMap, debounceTime, filter, + from, + last, map, merge, mergeMap, @@ -100,7 +104,18 @@ export const IssueStore = signalStore( activity = inject(ActivityGateway), realtime = inject(TrackerRealtimeGateway), notifications = inject(Notifications), + attachments = inject(AttachmentsGateway), ) => { + /** One file after another: the ticket's version moves with each. */ + const uploadAll = ( + issueId: string, + files: File[], + commentId: string | null = null, + ) => + from(files).pipe( + concatMap((file) => attachments.upload(issueId, file, commentId)), + last(), + ); const fetchIssue = (idOrKey: string) => issues.issue(idOrKey).pipe( retry({ @@ -267,8 +282,48 @@ export const IssueStore = signalStore( .pipe( mergeMap(({ payload }) => runCommand( - comments.add(payload.issueId, payload.body), - 'Comentario añadido', + comments + .add(payload.issueId, payload.body) + .pipe( + concatMap((comment) => + payload.files?.length + ? uploadAll( + payload.issueId, + payload.files, + comment.id, + ) + : [comment], + ), + ), + payload.files?.length + ? 'Comentario y adjuntos añadidos' + : 'Comentario añadido', + done, + failed, + ), + ), + ), + events + .on(issuePageEvents.attachRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + uploadAll(payload.issueId, payload.files, payload.commentId), + payload.files.length === 1 + ? `${payload.files[0].name} adjuntado` + : `${payload.files.length} archivos adjuntados`, + done, + failed, + ), + ), + ), + events + .on(issuePageEvents.attachmentRemoveRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + attachments.remove(payload.issueId, payload.attachmentId), + 'Adjunto eliminado', done, failed, ), diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index cae54be..12e249d 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -65,6 +65,18 @@ export interface IssuePage { total: number; } +/** A file on a ticket (or under one of its comments). */ +export interface Attachment { + id: string; + fileName: string; + mimeType: string; + /** Bytes. */ + size: number; + uploadedBy: UserRef; + uploadedAt: string; + commentId: string | null; +} + export interface Issue extends IssueSummary { description: string | null; reporter: UserRef; @@ -72,6 +84,7 @@ export interface Issue extends IssueSummary { storyPoints: number | null; dueDate: string | null; links: IssueLink[]; + attachments: Attachment[]; allowedTransitions: string[]; createdAt: string; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 24acfe1..fcc58f9 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -1,6 +1,7 @@ import { Observable } from 'rxjs'; import { Activity, + Attachment, BoardColumn, Comment, CommandResult, @@ -93,6 +94,21 @@ export abstract class CommentsGateway { abstract remove(commentId: string): Observable; } +/** A ticket's files. Uploads and downloads go through the REST API. */ +export abstract class AttachmentsGateway { + abstract upload( + issueId: string, + file: File, + commentId?: string | null, + ): Observable; + /** The file's bytes, to show (images) or save. */ + abstract download(issueId: string, attachment: Attachment): Observable; + abstract remove( + issueId: string, + attachmentId: string, + ): Observable; +} + export abstract class ReleasesGateway { abstract list(projectId: string): Observable; /** Planned releases of all my projects, soonest target date first. */ diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/http-attachments.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/http-attachments.gateway.ts new file mode 100644 index 0000000..900e89b --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/http-attachments.gateway.ts @@ -0,0 +1,73 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { + AccessTokenSource, + API_CONFIG, +} from '@task-stream/web/shared/infrastructure'; +import { + Attachment, + AttachmentsGateway, + CommandResult, +} from '@task-stream/web/tracker/domain'; +import { from, Observable, switchMap } from 'rxjs'; + +/** + * Files travel over REST (`/api/issues/:id/attachments`): GraphQL is not made + * for bytes. The ticket's list of files comes with the ticket itself. + */ +@Injectable() +export class HttpAttachmentsGateway extends AttachmentsGateway { + private readonly http = inject(HttpClient); + private readonly api = inject(API_CONFIG); + private readonly tokens = inject(AccessTokenSource); + + upload( + issueId: string, + file: File, + commentId?: string | null, + ): Observable { + const body = new FormData(); + body.append('file', file, file.name); + if (commentId) body.append('commentId', commentId); + return this.authorized((headers) => + this.http.post(this.url(issueId), body, { headers }), + ); + } + + download(issueId: string, attachment: Attachment): Observable { + return this.authorized((headers) => + this.http.get(this.url(issueId, attachment.id), { + headers, + responseType: 'blob', + }), + ); + } + + remove(issueId: string, attachmentId: string): Observable { + return this.authorized((headers) => + this.http.delete(this.url(issueId, attachmentId), { + headers, + }), + ); + } + + private url(issueId: string, attachmentId?: string): string { + const base = `${this.api.restUrl}/issues/${encodeURIComponent(issueId)}/attachments`; + return attachmentId ? `${base}/${encodeURIComponent(attachmentId)}` : base; + } + + /** Public projects can be read without a session: no token, no header. */ + private authorized( + request: (headers: HttpHeaders) => Observable, + ): Observable { + return from(this.tokens.accessToken()).pipe( + switchMap((token) => + request( + token + ? new HttpHeaders({ Authorization: `Bearer ${token}` }) + : new HttpHeaders(), + ), + ), + ); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index b114f7e..b3c5363 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -88,6 +88,17 @@ fragment IssueFields on Issue { issueId issueKey } + attachments { + id + fileName + mimeType + size + uploadedAt + commentId + uploadedBy { + ...UserRefFields + } + } } fragment CommentFields on Comment { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index bf6ec58..099042f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -237,6 +237,15 @@ export type IssueFieldsFragment = { issueId: string; issueKey: string; }>; + attachments: Array<{ + id: string; + fileName: string; + mimeType: string; + size: number; + uploadedAt: string; + commentId: string | null; + uploadedBy: { id: string; name: string }; + }>; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; }; @@ -342,6 +351,15 @@ export type IssueQuery = { issueId: string; issueKey: string; }>; + attachments: Array<{ + id: string; + fileName: string; + mimeType: string; + size: number; + uploadedAt: string; + commentId: string | null; + uploadedBy: { id: string; name: string }; + }>; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; }; @@ -1294,6 +1312,34 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attachments' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'fileName' } }, + { kind: 'Field', name: { kind: 'Name', value: 'mimeType' } }, + { kind: 'Field', name: { kind: 'Name', value: 'size' } }, + { kind: 'Field', name: { kind: 'Name', value: 'uploadedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentId' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'uploadedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, @@ -1877,6 +1923,34 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attachments' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'fileName' } }, + { kind: 'Field', name: { kind: 'Name', value: 'mimeType' } }, + { kind: 'Field', name: { kind: 'Name', value: 'size' } }, + { kind: 'Field', name: { kind: 'Name', value: 'uploadedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentId' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'uploadedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 6d4d953..bf37110 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -4,6 +4,7 @@ import { } from '@angular/core'; import { ActivityGateway, + AttachmentsGateway, CommentsGateway, IssuesGateway, ProjectsGateway, @@ -18,10 +19,11 @@ import { GraphqlUsersGateway, } from './adapters/graphql-collaboration.gateways'; import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway'; +import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; -/** Ports → GraphQL adapters. */ +/** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { return makeEnvironmentProviders([ { provide: ProjectsGateway, useClass: GraphqlProjectsGateway }, @@ -29,6 +31,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: CommentsGateway, useClass: GraphqlCommentsGateway }, { provide: ReleasesGateway, useClass: GraphqlReleasesGateway }, { provide: ActivityGateway, useClass: GraphqlActivityGateway }, + { provide: AttachmentsGateway, useClass: HttpAttachmentsGateway }, { provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, ]); diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index eefd26e..3617bea 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -338,3 +338,10 @@ a.ts-key { font-size: 0.8125rem; color: var(--ts-brand); } + +.ts-attachments { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 0.5rem; + margin: 0.5rem 0; +} diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 22c7c32..9a094ec 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -3,6 +3,7 @@ import { Component, computed, effect, + HostListener, inject, input, signal, @@ -13,6 +14,7 @@ import { form, FormField, required } from '@angular/forms/signals'; import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, + PiFileUploadComponent, PiIconButtonComponent, PiIconComponent, PiLabelDirective, @@ -21,15 +23,17 @@ import { PiSkeletonComponent, PiTextareaComponent, PiTextFieldComponent, + type PiFileRejection, type PiSelectFieldOption, } from '@pi/ui'; -import { DrawerService } from '@task-stream/web/shared/ui'; +import { DrawerService, ToastService } from '@task-stream/web/shared/ui'; import { IssueFacade, ProjectFacade, provideIssueFacade, } from '@task-stream/web/tracker/application'; import { + Attachment, Comment, IssueLink, IssueLinkType, @@ -46,8 +50,11 @@ import { timeAgo, typeLook, } from '../ui/copy'; +import { AttachmentItem } from '../ui/attachments'; const NONE = ''; +/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */ +const MAX_FILE_BYTES = 25 * 1024 * 1024; @Component({ selector: 'ts-issue-page', @@ -55,7 +62,9 @@ const NONE = ''; imports: [ RouterLink, FormField, + AttachmentItem, PiActionButtonComponent, + PiFileUploadComponent, PiIconComponent, PiIconButtonComponent, PiLabelDirective, @@ -134,6 +143,47 @@ const NONE = ''; } +
+

+ Adjuntos + {{ ticketFiles().length }} +

+ @if (ticketFiles().length) { +
+ @for (file of ticketFiles(); track file.id) { + + } +
+ } @else if (!project.canContribute()) { +

Sin adjuntos.

+ } + @if (project.canContribute()) { +
+ + @if (pendingFiles().length) { +
+ +
+ } +
+ } +
+

Relaciones

@for ( @@ -238,6 +288,16 @@ const NONE = ''; }

{{ comment.body }}

+ @if (filesOf(comment).length) { +
+ @for (file of filesOf(comment); track file.id) { + + } +
+ } } @@ -251,6 +311,14 @@ const NONE = ''; placeholder="Deja una decisión, una explicación, un paso para reproducir…" [(value)]="commentBody" /> +
>('reasonForm'); private readonly editForm = @@ -506,6 +575,21 @@ export class IssuePage { protected readonly linkType = signal(IssueLinkType.RELATES_TO); protected readonly linkTarget = signal(''); protected readonly commentBody = signal(''); + protected readonly commentFiles = signal([]); + protected readonly pendingFiles = signal([]); + protected readonly maxFileBytes = MAX_FILE_BYTES; + + /** Files on the ticket itself; those under a comment are shown there. */ + protected readonly ticketFiles = computed( + () => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [], + ); + protected filesOf = (comment: Comment): Attachment[] => + this.facade + .issue() + ?.attachments.filter((a) => a.commentId === comment.id) ?? []; + protected canRemove = (file: Attachment): boolean => + this.project.canContribute() && + (file.uploadedBy.id === this.facade.me() || this.project.canManage()); private pendingStatus: string | null = null; private readonly reasonModel = signal({ text: '' }); @@ -590,8 +674,53 @@ export class IssuePage { protected addComment(): void { const body = this.commentBody().trim(); if (!body) return; - this.facade.addComment(body); + this.facade.addComment(body, this.commentFiles()); this.commentBody.set(''); + this.commentFiles.set([]); + } + + protected attach(): void { + this.facade.attach(this.pendingFiles()); + this.pendingFiles.set([]); + } + + protected rejected(rejections: PiFileRejection[]): void { + for (const { file, reason } of rejections) { + this.toast.error( + reason === 'size' + ? `${file.name} pasa de 25 MB` + : reason === 'max-files' + ? 'Como mucho 10 archivos a la vez' + : `${file.name} no es un tipo admitido`, + ); + } + } + + /** A screenshot pasted anywhere on the ticket is attached to it. */ + @HostListener('document:paste', ['$event']) + protected pasted(event: ClipboardEvent): void { + const target = event.target as HTMLElement | null; + // Pasting text into a field is just typing. + if (target?.closest('input, textarea, [contenteditable]')) return; + if (!this.project.canContribute() || !this.facade.issue()) return; + const files = [...(event.clipboardData?.files ?? [])].filter( + (file) => file.size <= MAX_FILE_BYTES, + ); + if (!files.length) return; + event.preventDefault(); + this.facade.attach( + files.map((file) => + file.name && file.name !== 'image.png' + ? file + : new File( + [file], + `captura-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, + { + type: file.type, + }, + ), + ), + ); } protected openEditComment(comment: Comment): void { diff --git a/libs/web/tracker/presenters/src/lib/ui/attachments.ts b/libs/web/tracker/presenters/src/lib/ui/attachments.ts new file mode 100644 index 0000000..35867e6 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/attachments.ts @@ -0,0 +1,178 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + input, + OnInit, + signal, +} from '@angular/core'; +import { PiIconButtonComponent, PiIconComponent } from '@pi/ui'; +import { IssueFacade } from '@task-stream/web/tracker/application'; +import { Attachment } from '@task-stream/web/tracker/domain'; +import { timeAgo } from './copy'; + +/** Types the browser shows in a tab of its own; anything else is saved. */ +const OPENS_IN_TAB = + /^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/; +const PREVIEWED = /^image\/(png|jpe?g|gif|webp|avif)$/; + +export function readableSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +/** + * One file: a thumbnail for images, an icon otherwise. The bytes are fetched + * with the session's token (a plain link could not send it) and shown from a + * local object URL, released when the item goes. + */ +@Component({ + selector: 'ts-attachment', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiIconComponent, PiIconButtonComponent], + template: ` + + @if (removable()) { + + } + `, + styles: ` + :host { + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 0; + } + .ts-attachment__open { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + flex: 1; + padding: 0.375rem; + border: 1px solid var(--ts-line, rgba(127, 127, 127, 0.25)); + border-radius: 0.5rem; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + } + .ts-attachment__open:hover, + .ts-attachment__open:focus-visible { + border-color: currentColor; + } + .ts-attachment__thumb, + .ts-attachment__icon { + flex: none; + width: 3rem; + height: 3rem; + border-radius: 0.375rem; + object-fit: cover; + display: grid; + place-items: center; + background: rgba(127, 127, 127, 0.12); + } + .ts-attachment__text { + display: grid; + min-width: 0; + } + .ts-attachment__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ts-attachment__meta { + font-size: 0.8125rem; + opacity: 0.7; + } + `, +}) +export class AttachmentItem implements OnInit { + readonly attachment = input.required(); + readonly removable = input(false); + + private readonly facade = inject(IssueFacade); + private readonly destroyRef = inject(DestroyRef); + private objectUrl: string | null = null; + + protected readonly thumbnail = signal(null); + protected readonly isImage = computed(() => + PREVIEWED.test(this.attachment().mimeType), + ); + protected readonly size = computed(() => + readableSize(this.attachment().size), + ); + protected readonly ago = timeAgo; + + ngOnInit(): void { + this.destroyRef.onDestroy(() => { + if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); + }); + // Small images only: a 20 MB screenshot is not worth a thumbnail. + if (this.isImage() && this.attachment().size <= 5 * 1024 * 1024) { + this.withUrl((url) => this.thumbnail.set(url)); + } + } + + protected open(): void { + this.withUrl((url) => { + const { fileName, mimeType } = this.attachment(); + if (OPENS_IN_TAB.test(mimeType)) { + window.open(url, '_blank', 'noopener'); + return; + } + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + }); + } + + protected remove(): void { + if (confirm(`¿Quitar ${this.attachment().fileName}?`)) { + this.facade.removeAttachment(this.attachment().id); + } + } + + private withUrl(use: (url: string) => void): void { + if (this.objectUrl) { + use(this.objectUrl); + return; + } + this.facade.fileOf(this.attachment()).subscribe((blob) => { + this.objectUrl = URL.createObjectURL(blob); + use(this.objectUrl); + }); + } +}