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

@@ -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<string, Buffer>();
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);
}
}
}