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:
@@ -69,6 +69,18 @@ export const IssueSchema = new Schema<IssueDoc>(
|
||||
release: { _id: false, id: String, name: String },
|
||||
outwardLinks: [link],
|
||||
inwardLinks: [link],
|
||||
attachments: [
|
||||
{
|
||||
_id: false,
|
||||
id: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
size: Number,
|
||||
uploadedBy: userRef,
|
||||
uploadedAt: String,
|
||||
commentId: String,
|
||||
},
|
||||
],
|
||||
labels: [String],
|
||||
version: Number,
|
||||
},
|
||||
@@ -137,5 +149,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
id: _id,
|
||||
...rest,
|
||||
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
|
||||
// Tickets projected before attachments existed have none.
|
||||
attachments: rest.attachments ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
AttachmentReadModel,
|
||||
CommentReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
@@ -99,14 +100,29 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
version,
|
||||
...fields
|
||||
}: Pick<IssueReadModel, 'id' | 'version'> &
|
||||
Partial<Omit<IssueReadModel, 'links' | 'commentCount'>>) {
|
||||
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>) {
|
||||
await upsertIfNewer(this.issues, id, version, fields, {
|
||||
outwardLinks: [],
|
||||
inwardLinks: [],
|
||||
attachments: [],
|
||||
commentCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
||||
{ $push: { attachments: attachment } },
|
||||
);
|
||||
}
|
||||
|
||||
async removeAttachment(issueId: string, attachmentId: string) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ $pull: { attachments: { id: attachmentId } } },
|
||||
);
|
||||
}
|
||||
|
||||
async addLink(issueId: string, link: IssueLinkReadModel) {
|
||||
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
|
||||
await this.issues.updateOne(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve, sep } from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { AttachmentStorage } from '@task-stream/api/tracker/application';
|
||||
|
||||
/**
|
||||
* Attached files on disk, under ATTACHMENTS_DIR (a volume in Docker). Keys
|
||||
* come from ids this API generated, but are still checked to stay inside the
|
||||
* folder: a key is never trusted to be a path.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileSystemAttachmentStorage extends AttachmentStorage {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
super();
|
||||
this.root = resolve(
|
||||
config.get<string>('ATTACHMENTS_DIR') || 'data/attachments',
|
||||
);
|
||||
}
|
||||
|
||||
async put(key: string, content: Buffer): Promise<void> {
|
||||
const path = this.pathOf(key);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, content, { flag: 'wx' });
|
||||
}
|
||||
|
||||
async read(key: string): Promise<Readable> {
|
||||
const path = this.pathOf(key);
|
||||
try {
|
||||
await stat(path);
|
||||
} catch {
|
||||
throw new NotFoundError('Attachment', key.split('/').pop() ?? key);
|
||||
}
|
||||
return createReadStream(path);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await rm(this.pathOf(key), { force: true });
|
||||
}
|
||||
|
||||
async removePrefix(prefix: string): Promise<void> {
|
||||
await rm(this.pathOf(prefix), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
private pathOf(key: string): string {
|
||||
const path = resolve(this.root, key);
|
||||
if (path !== this.root && !path.startsWith(this.root + sep)) {
|
||||
throw new Error(`Attachment key escapes the storage folder: ${key}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
AttachmentStorage,
|
||||
ChangeFeed,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
|
||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||
|
||||
const PORTS = [
|
||||
// Write side lookups → PostgreSQL (aggregates themselves live in the event store)
|
||||
@@ -96,6 +98,8 @@ const PORTS = [
|
||||
provide: UpsertMaterializedTimelineRepository,
|
||||
useClass: MongoUpsertMaterializedTimelineRepository,
|
||||
},
|
||||
// Attached files → disk (ATTACHMENTS_DIR)
|
||||
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
// Identity context (anti-corruption layer)
|
||||
|
||||
Reference in New Issue
Block a user