feat: attachments on tickets and comments

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

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

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

View File

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