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

@@ -29,7 +29,21 @@ export const issuePageEvents = eventGroup({
issueId: string;
releaseId: string | null;
}>(),
commentAddRequested: type<{ issueId: string; body: string }>(),
commentAddRequested: type<{
issueId: string;
body: string;
/** Uploaded under the new comment once it exists. */
files?: File[];
}>(),
attachRequested: type<{
issueId: string;
files: File[];
commentId?: string | null;
}>(),
attachmentRemoveRequested: type<{
issueId: string;
attachmentId: string;
}>(),
commentEditRequested: type<{ commentId: string; body: string }>(),
commentDeleteRequested: type<{ commentId: string }>(),
},

View File

@@ -4,6 +4,7 @@ import { Events } from '@ngrx/signals/events';
import { AuthFacade } from '@task-stream/web/identity/application';
import {
ActivityGateway,
AttachmentsGateway,
CommentsGateway,
IssuesGateway,
Project,
@@ -45,7 +46,15 @@ function gateways() {
issue: vi.fn(() => of(issue)),
changeStatus: vi.fn(() => of(ok)),
},
list: { list: vi.fn(() => of([])) },
list: {
list: vi.fn(() => of([])),
add: vi.fn(() => of({ ...ok, id: 'c1' })),
},
attachments: {
upload: vi.fn(() => of(ok)),
download: vi.fn(() => of(new Blob(['x']))),
remove: vi.fn(() => of(ok)),
},
realtime: {
issueChanges: () => NEVER,
commentChanges: () => NEVER,
@@ -80,6 +89,7 @@ function setup() {
{ provide: ReleasesGateway, useValue: fakes.list },
{ provide: ActivityGateway, useValue: fakes.list },
{ provide: CommentsGateway, useValue: fakes.list },
{ provide: AttachmentsGateway, useValue: fakes.attachments },
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{
@@ -222,6 +232,24 @@ describe('IssueFacade', () => {
reason: 'Falla en Safari',
});
});
it('uploads files one by one, and under a new comment when sent with it', () => {
const { issue, fakes } = setup();
const a = new File(['a'], 'a.png', { type: 'image/png' });
const b = new File(['b'], 'b.log', { type: 'text/plain' });
issue.open('WEB-1');
issue.attach([a, b]);
issue.addComment('Adjunto la traza', [b]);
issue.removeAttachment('f1');
expect(fakes.attachments.upload.mock.calls).toEqual([
['i1', a, null],
['i1', b, null],
['i1', b, 'c1'],
]);
expect(fakes.attachments.remove).toHaveBeenCalledWith('i1', 'f1');
});
});
describe('HomeFacade', () => {

View File

@@ -1,10 +1,13 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
Attachment,
AttachmentsGateway,
IssueLink,
IssueLinkType,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
import { issuePageEvents } from '../events/issue.events';
import { IssueStore } from '../stores/issue.store';
@@ -16,6 +19,7 @@ import { IssueStore } from '../stores/issue.store';
export class IssueFacade {
private readonly store = inject(IssueStore);
private readonly dispatch = injectDispatch(issuePageEvents);
private readonly files = inject(AttachmentsGateway);
readonly issue = this.store.issue;
readonly comments = this.store.comments;
@@ -74,12 +78,33 @@ export class IssueFacade {
);
}
addComment(body: string): void {
/** `files` are attached under the new comment. */
addComment(body: string, files: File[] = []): void {
this.withIssue((issueId) =>
this.dispatch.commentAddRequested({ issueId, body }),
this.dispatch.commentAddRequested({ issueId, body, files }),
);
}
/** Attaches files to the ticket (or under one of its comments). */
attach(files: File[], commentId: string | null = null): void {
if (!files.length) return;
this.withIssue((issueId) =>
this.dispatch.attachRequested({ issueId, files, commentId }),
);
}
removeAttachment(attachmentId: string): void {
this.withIssue((issueId) =>
this.dispatch.attachmentRemoveRequested({ issueId, attachmentId }),
);
}
/** The file's bytes, to show an image or save the file. */
fileOf(attachment: Attachment): Observable<Blob> {
const issueId = this.store.issue()?.id ?? '';
return this.files.download(issueId, attachment);
}
editComment(commentId: string, body: string): void {
this.dispatch.commentEditRequested({ commentId, body });
}

View File

@@ -11,6 +11,7 @@ import { AuthFacade } from '@task-stream/web/identity/application';
import {
Activity,
ActivityGateway,
AttachmentsGateway,
Comment,
CommentsGateway,
Issue,
@@ -18,8 +19,11 @@ import {
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
concatMap,
debounceTime,
filter,
from,
last,
map,
merge,
mergeMap,
@@ -100,7 +104,18 @@ export const IssueStore = signalStore(
activity = inject(ActivityGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
attachments = inject(AttachmentsGateway),
) => {
/** One file after another: the ticket's version moves with each. */
const uploadAll = (
issueId: string,
files: File[],
commentId: string | null = null,
) =>
from(files).pipe(
concatMap((file) => attachments.upload(issueId, file, commentId)),
last(),
);
const fetchIssue = (idOrKey: string) =>
issues.issue(idOrKey).pipe(
retry({
@@ -267,8 +282,48 @@ export const IssueStore = signalStore(
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.add(payload.issueId, payload.body),
'Comentario añadido',
comments
.add(payload.issueId, payload.body)
.pipe(
concatMap((comment) =>
payload.files?.length
? uploadAll(
payload.issueId,
payload.files,
comment.id,
)
: [comment],
),
),
payload.files?.length
? 'Comentario y adjuntos añadidos'
: 'Comentario añadido',
done,
failed,
),
),
),
events
.on(issuePageEvents.attachRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
uploadAll(payload.issueId, payload.files, payload.commentId),
payload.files.length === 1
? `${payload.files[0].name} adjuntado`
: `${payload.files.length} archivos adjuntados`,
done,
failed,
),
),
),
events
.on(issuePageEvents.attachmentRemoveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
attachments.remove(payload.issueId, payload.attachmentId),
'Adjunto eliminado',
done,
failed,
),