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:
@@ -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 }>(),
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user