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

@@ -1,10 +1,13 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
Attachment,
AttachmentsGateway,
IssueLink,
IssueLinkType,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
import { issuePageEvents } from '../events/issue.events';
import { IssueStore } from '../stores/issue.store';
@@ -16,6 +19,7 @@ import { IssueStore } from '../stores/issue.store';
export class IssueFacade {
private readonly store = inject(IssueStore);
private readonly dispatch = injectDispatch(issuePageEvents);
private readonly files = inject(AttachmentsGateway);
readonly issue = this.store.issue;
readonly comments = this.store.comments;
@@ -74,12 +78,33 @@ export class IssueFacade {
);
}
addComment(body: string): void {
/** `files` are attached under the new comment. */
addComment(body: string, files: File[] = []): void {
this.withIssue((issueId) =>
this.dispatch.commentAddRequested({ issueId, body }),
this.dispatch.commentAddRequested({ issueId, body, files }),
);
}
/** Attaches files to the ticket (or under one of its comments). */
attach(files: File[], commentId: string | null = null): void {
if (!files.length) return;
this.withIssue((issueId) =>
this.dispatch.attachRequested({ issueId, files, commentId }),
);
}
removeAttachment(attachmentId: string): void {
this.withIssue((issueId) =>
this.dispatch.attachmentRemoveRequested({ issueId, attachmentId }),
);
}
/** The file's bytes, to show an image or save the file. */
fileOf(attachment: Attachment): Observable<Blob> {
const issueId = this.store.issue()?.id ?? '';
return this.files.download(issueId, attachment);
}
editComment(commentId: string, body: string): void {
this.dispatch.commentEditRequested({ commentId, body });
}