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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user