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:
135
repositories/AttachmentRepository.ts
Normal file
135
repositories/AttachmentRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { FileAsset } from '@/lib/types';
|
||||
import type { FileAsset, FileAssetSource } from '@/lib/types';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
@ -17,6 +17,7 @@ interface FileAssetRow {
|
||||
mime_type: string;
|
||||
size: number;
|
||||
path: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
@ -31,6 +32,7 @@ function mapFile(row: FileAssetRow): FileAsset {
|
||||
mimeType: row.mime_type,
|
||||
size: row.size,
|
||||
path: row.path,
|
||||
source: row.source as FileAssetSource,
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
};
|
||||
@ -44,6 +46,7 @@ export interface CreateFileInput {
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
source?: FileAssetSource;
|
||||
}
|
||||
|
||||
export class FileRepository {
|
||||
@ -55,15 +58,17 @@ export class FileRepository {
|
||||
pageSize: number = DEFAULT_PAGE_SIZE
|
||||
): Paginated<FileAsset> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
// 添付専用ファイル(source='attachment')はFiles一覧に出さない
|
||||
const items = this.db.query<FileAssetRow>(
|
||||
`SELECT * FROM file_assets
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @pageSize OFFSET @offset`,
|
||||
{ projectId, pageSize, offset }
|
||||
);
|
||||
const total = this.db.get<{ count: number }>(
|
||||
'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL',
|
||||
`SELECT COUNT(*) AS count FROM file_assets
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'`,
|
||||
{ projectId }
|
||||
);
|
||||
return { items: items.map(mapFile), total: total?.count ?? 0 };
|
||||
@ -79,9 +84,10 @@ export class FileRepository {
|
||||
|
||||
create(input: CreateFileInput): FileAsset {
|
||||
const now = new Date().toISOString();
|
||||
const source = input.source ?? 'library';
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, source, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @source, @createdAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
uploaderId: input.uploaderId,
|
||||
@ -90,6 +96,7 @@ export class FileRepository {
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
path: input.path,
|
||||
source,
|
||||
createdAt: now,
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user