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,24 @@
import type { Readable } from 'node:stream';
/** Where the bytes of attached files live (the tickets only record them). */
export abstract class AttachmentStorage {
abstract put(key: string, content: Buffer): Promise<void>;
/** Fails with NotFoundError when there is nothing at `key`. */
abstract read(key: string): Promise<Readable>;
/** Idempotent: removing what is not there is fine. */
abstract remove(key: string): Promise<void>;
/** Everything under `prefix` (a ticket's files, when it is deleted). */
abstract removePrefix(prefix: string): Promise<void>;
}
/** One folder per project and ticket, so a ticket's files go together. */
export const attachmentKey = (
projectId: string,
issueId: string,
attachmentId: string,
): string => `${projectId}/${issueId}/${attachmentId}`;
export const issueAttachmentsPrefix = (
projectId: string,
issueId: string,
): string => `${projectId}/${issueId}/`;

View File

@@ -17,3 +17,4 @@ export * from './release-issues.repository';
export * from './upsert-materialized-timeline.repository';
export * from './find-timeline.repository';
export * from './tracker-streams.repository';
export * from './attachment-storage';

View File

@@ -1,4 +1,5 @@
import {
AttachmentReadModel,
IssueLinkReadModel,
IssueReadModel,
StatusCategory,
@@ -8,7 +9,7 @@ export abstract class UpsertMaterializedIssueRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
issue: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount'>>,
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>,
): Promise<void>;
/** Adds a relation to an issue (idempotent). */
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
@@ -16,6 +17,15 @@ export abstract class UpsertMaterializedIssueRepository {
issueId: string,
link: Omit<IssueLinkReadModel, 'issueKey'>,
): Promise<void>;
/** Adds a file to an issue (idempotent). */
abstract addAttachment(
issueId: string,
attachment: AttachmentReadModel,
): Promise<void>;
abstract removeAttachment(
issueId: string,
attachmentId: string,
): Promise<void>;
abstract setCommentCount(
issueId: string,
commentCount: number,