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

@@ -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,
),