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:
117
apps/api/scripts/contracts.ts
Normal file
117
apps/api/scripts/contracts.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user