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,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']);
});
});
});