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

@@ -22,3 +22,5 @@ export * from './release-archived.event';
export * from './project-deleted.event';
export * from './issue-deleted.event';
export * from './release-deleted.event';
export * from './issue-attachment-added.event';
export * from './issue-attachment-removed.event';

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueAttachment } from '../issue/issue-attachment';
import { TrackerEvent } from './tracker.event';
/** A file was attached to a ticket (or to one of its comments). */
@AutowiredEvent
export class IssueAttachmentAddedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly attachment: IssueAttachment,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A file was removed from a ticket. */
@AutowiredEvent
export class IssueAttachmentRemovedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly attachmentId: string,
readonly fileName: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,35 @@
import { assertThat } from '@task-stream/api/shared/domain';
/**
* A file attached to a ticket — a screenshot, a log, a short video. The bytes
* live in the attachment storage; the ticket only records that they exist.
* `commentId` places it under one of the ticket's comments.
*/
export interface IssueAttachment {
id: string;
fileName: string;
mimeType: string;
/** Bytes. */
size: number;
uploadedBy: string;
uploadedAt: string;
commentId: string | null;
}
/** A ticket keeps at most this many files. */
export const MAX_ATTACHMENTS_PER_ISSUE = 50;
/** Trims the name and keeps only its last path segment. */
export function normalizeAttachment(
attachment: IssueAttachment,
): IssueAttachment {
const fileName =
attachment.fileName.split(/[\\/]/).pop()?.trim().slice(0, 200) ?? '';
assertThat(fileName.length > 0, 'An attachment needs a file name');
assertThat(attachment.size > 0, 'An attachment cannot be empty');
return {
...attachment,
fileName,
mimeType: attachment.mimeType || 'application/octet-stream',
};
}

View File

@@ -1,5 +1,6 @@
import {
BusinessRuleError,
ForbiddenError,
ValidationError,
} from '@task-stream/api/shared/domain';
import { IssueCreatedEvent } from '../events/issue-created.event';
@@ -154,4 +155,55 @@ describe('Issue', () => {
});
expect(rebuilt.getUncommittedEvents()).toHaveLength(0);
});
describe('attachments', () => {
const file = (id: string, uploadedBy = 'u-qa') => ({
id,
fileName: 'C:\\Users\\qa\\captura login.png',
mimeType: 'image/png',
size: 2048,
uploadedBy,
uploadedAt: '2026-10-01T10:00:00.000Z',
commentId: null,
});
it('records a file with its name cleaned up, once', () => {
const issue = newIssue();
issue.attach('u-qa', file('a-1'));
issue.attach('u-qa', file('a-1'));
expect(issue.attachments).toHaveLength(1);
expect(issue.attachments[0].fileName).toBe('captura login.png');
});
it('lets the uploader or a manager remove it, nobody else', () => {
const issue = newIssue();
issue.attach('u-qa', file('a-1'));
expect(() => issue.detach('u-dev', 'a-1', false)).toThrow(ForbiddenError);
issue.detach('u-dev', 'a-1', true);
expect(issue.attachments).toEqual([]);
expect(() => issue.detach('u-qa', 'a-1', true)).toThrow(
BusinessRuleError,
);
});
it('rejects empty files', () => {
expect(() =>
newIssue().attach('u-qa', { ...file('a-1'), size: 0 }),
).toThrow(ValidationError);
});
it('survives a replay', () => {
const issue = newIssue();
issue.attach('u-qa', file('a-1'));
issue.attach('u-qa', file('a-2'));
issue.detach('u-qa', 'a-1', false);
const { rebuilt } = replay(issue, Issue);
expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']);
});
});
});

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;
}

View File

@@ -12,6 +12,17 @@ export class IssueLinkReadModel {
issueKey!: string;
}
export class AttachmentReadModel {
id!: string;
fileName!: string;
mimeType!: string;
size!: number;
uploadedBy!: UserRefReadModel;
uploadedAt!: string;
/** Shown under this comment when set. */
commentId!: string | null;
}
export class IssueReadModel {
id!: string;
projectId!: string;
@@ -35,6 +46,7 @@ export class IssueReadModel {
storyPoints!: number | null;
dueDate!: string | null;
links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[];
commentCount!: number;
createdAt!: string;
updatedAt!: string;