feat(chat,board): file/image attachments on chat, board threads and comments

チャット・掲示板(スレッド/コメント)へファイル/画像添付を追加。

- lib/db/migrations/002_attachments.sql: attachments テーブル + file_assets.source 列
- repositories/AttachmentRepository + services/AttachmentService: 添付紐付け(プロジェクト/アップロード者チェック)
- FileStorageService.uploadForAttachment: 通知/SSE/アクティビティを行わない添付専用アップロード(source='attachment')
- ChatService/BoardService: fileIds 受理・添付紐付け・履歴/詳細表示・削除クリーンアップ(トランザクション)
- API: POST /attachments + chat/board の fileIds 受理
- UI: AttachmentList(画像サムネイル+Lightbox/ダウンロード) + AttachmentPicker(複数アップロード・送信中は送信不可) をチャット/スレッド/コメントに統合
- 添付ファイルは既存の /api/files/[fileId]/download(権限チェック済)で配信
This commit is contained in:
Ken Yasue
2026-06-25 10:07:02 +02:00
parent c747978f3d
commit 25d800a529
31 changed files with 1640 additions and 102 deletions

View File

@ -0,0 +1,135 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type {
Attachment,
AttachmentTargetType,
AttachmentView,
} from '@/lib/types';
interface AttachmentRow {
id: number;
project_id: number;
file_id: number;
target_type: string;
target_id: number;
created_at: string;
deleted_at: string | null;
}
interface AttachmentViewRow {
id: number;
file_id: number;
target_type: string;
target_id: number;
original_name: string;
mime_type: string;
size: number;
}
function mapAttachment(row: AttachmentRow): Attachment {
return {
id: row.id,
projectId: row.project_id,
fileId: row.file_id,
targetType: row.target_type as AttachmentTargetType,
targetId: row.target_id,
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
}
function mapView(row: AttachmentViewRow): AttachmentView {
return {
id: row.id,
fileId: row.file_id,
targetType: row.target_type as AttachmentTargetType,
targetId: row.target_id,
originalName: row.original_name,
mimeType: row.mime_type,
size: row.size,
};
}
export interface CreateAttachmentInput {
projectId: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
}
/**
* attachments テーブルのCRUDを担うRepository。
* file_assets とJOINして表示用ビューを返す。
*/
export class AttachmentRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateAttachmentInput): Attachment {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO attachments (project_id, file_id, target_type, target_id, created_at, deleted_at)
VALUES (@projectId, @fileId, @targetType, @targetId, @createdAt, NULL)`,
{
projectId: input.projectId,
fileId: input.fileId,
targetType: input.targetType,
targetId: input.targetId,
createdAt: now,
}
);
const created = this.findById(Number(result.lastInsertRowid));
if (!created) throw new Error('Failed to create attachment');
return created;
}
findById(id: number): Attachment | null {
const row = this.db.get<AttachmentRow>(
'SELECT * FROM attachments WHERE id = @id AND deleted_at IS NULL',
{ id }
);
return row ? mapAttachment(row) : null;
}
findByTarget(
targetType: AttachmentTargetType,
targetId: number
): Attachment[] {
const rows = this.db.query<AttachmentRow>(
`SELECT * FROM attachments
WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL
ORDER BY id ASC`,
{ targetType, targetId }
);
return rows.map(mapAttachment);
}
/** 複数ターゲットの添付ビューを一括取得(N+1回避)。 */
findViewsByTargets(
targetType: AttachmentTargetType,
targetIds: number[]
): AttachmentView[] {
if (targetIds.length === 0) return [];
const placeholders = targetIds.map(() => '?').join(',');
const rows = this.db.query<AttachmentViewRow>(
`SELECT a.id AS id, a.file_id AS file_id, a.target_type AS target_type,
a.target_id AS target_id, f.original_name AS original_name,
f.mime_type AS mime_type, f.size AS size
FROM attachments a
JOIN file_assets f ON f.id = a.file_id
WHERE a.target_type = ? AND a.deleted_at IS NULL
AND f.deleted_at IS NULL
AND a.target_id IN (${placeholders})
ORDER BY a.target_id ASC, a.id ASC`,
[targetType, ...targetIds]
);
return rows.map(mapView);
}
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時のクリーンアップ)。 */
deleteByTarget(targetType: AttachmentTargetType, targetId: number): boolean {
const result = this.db.execute(
'UPDATE attachments SET deleted_at = @now WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL',
{ now: new Date().toISOString(), targetType, targetId }
);
return result.changes > 0;
}
}