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

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

View File

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