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,9 +1,12 @@
import {
BusinessRuleError,
ForbiddenError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { IssueAssignedEvent } from '../events/issue-assigned.event';
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueDeletedEvent } from '../events/issue-deleted.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
@@ -12,6 +15,11 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { ProjectWorkflow } from '../project/project-settings';
import {
IssueAttachment,
MAX_ATTACHMENTS_PER_ISSUE,
normalizeAttachment,
} from './issue-attachment';
import { IssueLinkType, IssuePriority } from './issue.enums';
export interface IssueLink {
@@ -82,6 +90,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public storyPoints: number | null = null;
public dueDate: string | null = null;
public links: IssueLink[] = [];
public attachments: IssueAttachment[] = [];
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -216,6 +225,43 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/** Records a file stored for this ticket (or one of its comments). */
attach(actorId: string, attachment: IssueAttachment): void {
const next = normalizeAttachment(attachment);
if (this.attachments.some((a) => a.id === next.id)) return;
if (this.attachments.length >= MAX_ATTACHMENTS_PER_ISSUE) {
throw new BusinessRuleError(
`${this.key} already has ${MAX_ATTACHMENTS_PER_ISSUE} attachments; remove one first`,
);
}
this.apply(
new IssueAttachmentAddedEvent(actorId, this.id, this.projectId, next),
);
}
/**
* Removes a file. Whoever uploaded it may, and so may whoever manages the
* project (`canManage`).
*/
detach(actorId: string, attachmentId: string, canManage: boolean): void {
const attachment = this.attachments.find((a) => a.id === attachmentId);
if (!attachment) throw new BusinessRuleError('Attachment not found');
if (attachment.uploadedBy !== actorId && !canManage) {
throw new ForbiddenError(
'Only whoever uploaded a file, or a project admin, can remove it',
);
}
this.apply(
new IssueAttachmentRemovedEvent(
actorId,
this.id,
this.projectId,
attachment.id,
attachment.fileName,
),
);
}
/** Deletes the ticket (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
@@ -276,6 +322,18 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.updatedAt = event.occurredAt;
}
onIssueAttachmentAddedEvent(event: IssueAttachmentAddedEvent): void {
this.attachments = [...this.attachments, { ...event.attachment }];
this.updatedAt = event.occurredAt;
}
onIssueAttachmentRemovedEvent(event: IssueAttachmentRemovedEvent): void {
this.attachments = this.attachments.filter(
(a) => a.id !== event.attachmentId,
);
this.updatedAt = event.occurredAt;
}
onIssueDeletedEvent(): void {
this.deleted = true;
}