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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
35
libs/api/tracker/domain/src/lib/issue/issue-attachment.ts
Normal file
35
libs/api/tracker/domain/src/lib/issue/issue-attachment.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user