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

@@ -69,6 +69,18 @@ export const IssueSchema = new Schema<IssueDoc>(
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 ?? [],
};
}

View File

@@ -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<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount'>>) {
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>) {
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(

View File

@@ -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<string>('ATTACHMENTS_DIR') || 'data/attachments',
);
}
async put(key: string, content: Buffer): Promise<void> {
const path = this.pathOf(key);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content, { flag: 'wx' });
}
async read(key: string): Promise<Readable> {
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<void> {
await rm(this.pathOf(key), { force: true });
}
async removePrefix(prefix: string): Promise<void> {
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;
}
}

View File

@@ -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)