From 2b9732482c5b4cd4382150b42258b87c5cae2052 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 7 Sep 2025 23:59:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=A2=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convex File Storage を利用したファイルアップロード機能を追加 - チャット内でファイル添付・送信が可能 - ドラッグ&ドロップ、複数ファイル対応 - 画像プレビュー・ダウンロード機能 - Organization ベースのアクセス制御 Components: - FileUploader: メインアップロードコンポーネント - FilePreview: ファイルプレビュー表示 - FileAttachment: メッセージ内添付ファイル表示 - MessageInput: ファイル添付機能を追加 - MessageList: 添付ファイル表示機能を追加 Backend: - files.ts: ファイル関連 API (CRUD操作) - schema.ts: files テーブル追加、messages テーブル拡張 Style: DaisyUI/TailwindCSS による統一デザイン 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 11 + docs/file-upload/implement.md | 380 +++++++++++++++++ .../src/components/chat/MessageInput.svelte | 121 +++++- .../src/components/chat/MessageList.svelte | 10 + .../components/files/FileAttachment.svelte | 224 ++++++++++ .../src/components/files/FilePreview.svelte | 162 ++++++++ .../src/components/files/FileUploader.svelte | 381 ++++++++++++++++++ packages/convex/src/convex/files.ts | 230 +++++++++++ packages/convex/src/convex/messages.ts | 18 + packages/convex/src/convex/schema.ts | 20 + 10 files changed, 1544 insertions(+), 13 deletions(-) create mode 100644 docs/file-upload/implement.md create mode 100644 packages/client/src/components/files/FileAttachment.svelte create mode 100644 packages/client/src/components/files/FilePreview.svelte create mode 100644 packages/client/src/components/files/FileUploader.svelte create mode 100644 packages/convex/src/convex/files.ts diff --git a/CLAUDE.md b/CLAUDE.md index 76a5d33..3ca5b50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,9 +101,18 @@ When working with Svelte code, always reference the latest documentation: - `@@` → `../..` (workspace root) - `$components` → `src/components` - `~` → `src/` + - `@packages/{package}` → monorepo - **Convex Integration**: Uses `convex-svelte` for reactive queries - **State Pattern**: Logic components (e.g., TaskList.svelte) separate from presentation (TaskListSkin.svelte) +### Convex の Import について + +```ts +import { api, type Id } from "@packages/convex"; + +// use api and type Id ... +``` + ### 注意点: convex-svelte の `useQuery` について `useQuery` に渡す引数は、関数の形式で渡してください。そうでないと、期待しない動作を引き起こす可能性があります。 @@ -199,3 +208,5 @@ Tauri conflicts with the web development server and requires more resources for - Always prefer using DaisyUI classes, and use minimal Tailwind classes. - Separate components into smallest pieces for readability. - Name snippets with camelCase instead of PascalCase to avoid confusion with components. +- Always run `bun check` after writing code. +- Don't use style blocks in Svelte components, instead use TailwindCSS and DaisyUI. diff --git a/docs/file-upload/implement.md b/docs/file-upload/implement.md new file mode 100644 index 0000000..86337ba --- /dev/null +++ b/docs/file-upload/implement.md @@ -0,0 +1,380 @@ +# ファイルアップロード機能 設計書 + +## 概要 + +Prismチャットアプリケーションにおけるファイルアップロード機能の詳細設計書です。 + +## システム構成 + +- **フロントエンド**: SvelteKit (Svelte 5 runes mode) +- **バックエンド**: Convex (リアルタイムデータベース & API) +- **ファイルストレージ**: Convex File Storage +- **認証**: Convex Auth + +## 機能要件 + +### 基本機能 + +- ✅ チャット内でのファイル添付・アップロード +- ✅ ドラッグ&ドロップによるファイルアップロード +- ✅ 複数ファイルの同時アップロード +- ✅ アップロード進捗表示 +- ✅ ファイルプレビュー(画像) +- ✅ ファイルダウンロード + +### 対応ファイル形式 + +- **画像**: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp` +- **文書**: `.pdf`, `.txt`, `.doc`, `.docx` +- **その他**: 一般的なファイル形式 + +### 制限事項 + +- **ファイルサイズ**: 最大 10MB +- **同時アップロード**: 最大 5ファイル +- **権限**: Organization/Channel メンバーのみ + +## データベース設計 + +### 新しいテーブル: `files` + +```typescript +files: defineTable({ + // Convex Storage ID + storageId: v.string(), + // ファイル情報 + filename: v.string(), + originalFilename: v.string(), + mimeType: v.string(), + size: v.number(), // bytes + // メタデータ + uploadedBy: v.id("users"), + uploadedAt: v.number(), + organizationId: v.id("organizations"), + // 画像の場合の追加情報 + width: v.optional(v.number()), + height: v.optional(v.number()), +}) + .index("by_organization", ["organizationId"]) + .index("by_uploader", ["uploadedBy"]); +``` + +### 既存テーブル拡張: `messages` + +```typescript +messages: defineTable({ + channelId: v.id("channels"), + content: v.string(), + author: v.string(), + createdAt: v.number(), + parentId: v.optional(v.id("messages")), + // 添付ファイル (新規追加) + attachments: v.optional(v.array(v.id("files"))), +}).index("by_channel", ["channelId"]); +``` + +## API設計 (Convex) + +### Mutations + +#### `generateUploadUrl` + +アップロード用の署名付きURLを生成します。 + +```typescript +generateUploadUrl: mutation({ + args: { + organizationId: v.id("organizations"), + }, + handler: async (ctx, { organizationId }) => { + // 認証・権限チェック + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("認証が必要です"); + + await checkOrganizationMember(ctx, organizationId, identity.subject); + + return await ctx.storage.generateUploadUrl(); + }, +}); +``` + +#### `saveFileInfo` + +アップロード後のファイル情報をデータベースに保存します。 + +```typescript +saveFileInfo: mutation({ + args: { + storageId: v.string(), + filename: v.string(), + originalFilename: v.string(), + mimeType: v.string(), + size: v.number(), + organizationId: v.id("organizations"), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("認証が必要です"); + + // ファイルサイズ制限チェック + if (args.size > 10 * 1024 * 1024) { + // 10MB + throw new Error("ファイルサイズが大きすぎます(最大10MB)"); + } + + return await ctx.db.insert("files", { + ...args, + uploadedBy: identity.subject, + uploadedAt: Date.now(), + }); + }, +}); +``` + +#### `deleteFile` + +ファイルを削除します。 + +```typescript +deleteFile: mutation({ + args: { fileId: v.id("files") }, + handler: async (ctx, { fileId }) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("認証が必要です"); + + const file = await ctx.db.get(fileId); + if (!file) throw new Error("ファイルが見つかりません"); + + // 権限チェック(アップロード者またはadmin) + if (file.uploadedBy !== identity.subject) { + await checkOrganizationAdmin(ctx, file.organizationId, identity.subject); + } + + // ストレージからファイルを削除 + await ctx.storage.delete(file.storageId); + + // データベースからレコードを削除 + await ctx.db.delete(fileId); + }, +}); +``` + +### Queries + +#### `getFile` + +ファイル情報とアクセスURLを取得します。 + +```typescript +getFile: query({ + args: { fileId: v.id("files") }, + handler: async (ctx, { fileId }) => { + const file = await ctx.db.get(fileId); + if (!file) return null; + + const identity = await ctx.auth.getUserIdentity(); + if (identity) { + await checkOrganizationMember(ctx, file.organizationId, identity.subject); + } + + const url = await ctx.storage.getUrl(file.storageId); + return { ...file, url }; + }, +}); +``` + +#### `listFiles` + +Organization内のファイル一覧を取得します。 + +```typescript +listFiles: query({ + args: { + organizationId: v.id("organizations"), + limit: v.optional(v.number()), + }, + handler: async (ctx, { organizationId, limit = 50 }) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return []; + + await checkOrganizationMember(ctx, organizationId, identity.subject); + + const files = await ctx.db + .query("files") + .withIndex("by_organization", (q) => + q.eq("organizationId", organizationId), + ) + .order("desc") + .take(limit); + + return Promise.all( + files.map(async (file) => ({ + ...file, + url: await ctx.storage.getUrl(file.storageId), + })), + ); + }, +}); +``` + +## フロントエンド設計 + +### コンポーネント構成 + +#### 1. FileUploader.svelte + +メインのファイルアップロードコンポーネント + +**Props:** + +- `organizationId: string` - アップロード先のOrganization ID +- `onUpload?: (files: FileInfo[]) => void` - アップロード完了時のコールバック + +**Features:** + +- ドラッグ&ドロップエリア +- ファイル選択ボタン +- 複数ファイル選択 +- アップロード進捗表示 +- バリデーション(サイズ・形式) + +#### 2. FilePreview.svelte + +ファイルのプレビュー表示コンポーネント + +**Props:** + +- `file: File | FileInfo` - プレビューするファイル +- `removable?: boolean` - 削除ボタンの表示制御 +- `onRemove?: () => void` - 削除時のコールバック + +**Features:** + +- 画像プレビュー +- ファイル情報表示(名前・サイズ・形式) +- 削除ボタン + +#### 3. FileAttachment.svelte + +メッセージ内の添付ファイル表示コンポーネント + +**Props:** + +- `fileId: string` - ファイルID +- `compact?: boolean` - コンパクト表示モード + +**Features:** + +- ファイル情報表示 +- ダウンロードリンク +- 画像のインラインプレビュー + +#### 4. MessageInput.svelte (拡張) + +既存のメッセージ入力コンポーネントを拡張 + +**追加Features:** + +- ファイル添付ボタン +- 添付ファイル一覧表示 +- 添付ファイル付きメッセージ送信 + +### アップロードフロー + +1. **ファイル選択/ドロップ** + - ファイルバリデーション + - プレビュー表示 + +2. **アップロード開始** + - `generateUploadUrl` を呼び出し + - Convex Storage へファイルアップロード + - 進捗表示 + +3. **メタデータ保存** + - `saveFileInfo` を呼び出し + - ファイル情報をデータベースに保存 + +4. **メッセージ送信** (任意) + - 添付ファイルIDを含むメッセージを送信 + +### エラーハンドリング + +- **ファイルサイズエラー**: "ファイルサイズが大きすぎます(最大10MB)" +- **形式エラー**: "サポートされていないファイル形式です" +- **ネットワークエラー**: "アップロードに失敗しました。再試行してください" +- **権限エラー**: "ファイルのアップロード権限がありません" + +## セキュリティ + +### 認証・認可 + +- **アップロード**: ログインユーザーのみ +- **アクセス**: Organization メンバーのみ +- **削除**: アップロード者またはOrganization admin + +### ファイル検証 + +- **MIMEタイプ**: クライアント・サーバー両方で検証 +- **ファイルサイズ**: 10MB制限 +- **ファイル名**: サニタイズ処理 + +### アクセス制御 + +- **プライベートURL**: 署名付きURL使用 +- **権限チェック**: ファイルアクセス時に毎回確認 + +## パフォーマンス最適化 + +### アップロード最適化 + +- **並行アップロード**: 複数ファイルの並行処理 +- **チャンク分割**: 大ファイルの分割アップロード(将来実装) +- **レジューム**: 中断されたアップロードの再開(将来実装) + +### 表示最適化 + +- **遅延読み込み**: 画像の lazy loading +- **サムネイル**: 小さいプレビュー画像生成(将来実装) +- **キャッシュ**: ファイルURLのキャッシュ + +## 実装順序 + +### Phase 1: 基盤整備 + +- [ ] データベーススキーマ更新 +- [ ] 基本的なConvex API実装 +- [ ] 権限チェック関数の実装 + +### Phase 2: ファイルアップロード + +- [ ] FileUploader コンポーネント実装 +- [ ] ドラッグ&ドロップ機能 +- [ ] アップロード進捗表示 + +### Phase 3: プレビュー・表示 + +- [ ] FilePreview コンポーネント実装 +- [ ] FileAttachment コンポーネント実装 +- [ ] 画像プレビュー機能 + +### Phase 4: メッセージ統合 + +- [ ] MessageInput コンポーネント拡張 +- [ ] 添付ファイル付きメッセージ機能 +- [ ] MessageList での添付ファイル表示 + +### Phase 5: 最適化・テスト + +- [ ] エラーハンドリング強化 +- [ ] パフォーマンス最適化 +- [ ] E2Eテスト実装 + +## 将来の拡張予定 + +- **ファイル管理画面**: Organization内のファイル管理機能 +- **高度なプレビュー**: PDF, 動画のプレビュー +- **ファイル検索**: ファイル名・メタデータ検索 +- **自動削除**: 古いファイルの自動削除機能 +- **帯域幅最適化**: 画像圧縮・リサイズ機能 diff --git a/packages/client/src/components/chat/MessageInput.svelte b/packages/client/src/components/chat/MessageInput.svelte index 29546a0..e7ce307 100644 --- a/packages/client/src/components/chat/MessageInput.svelte +++ b/packages/client/src/components/chat/MessageInput.svelte @@ -2,6 +2,8 @@ import { api, type Id } from "@packages/convex"; import type { Doc } from "@packages/convex/src/convex/_generated/dataModel"; import { useQuery } from "convex-svelte"; + import FilePreview from "$components/files/FilePreview.svelte"; + import FileUploader from "$components/files/FileUploader.svelte"; import { useMutation } from "~/lib/useMutation.svelte.ts"; interface Props { @@ -9,13 +11,27 @@ replyingTo: Doc<"messages"> | null; } + interface UploadedFile { + id: Id<"files">; + filename: string; + originalFilename: string; + mimeType: string; + size: number; + url?: string; + } + let { channelId, replyingTo = $bindable() }: Props = $props(); const sendMessageMutation = useMutation(api.messages.send); const identity = useQuery(api.users.me, {}); + // Get channel info to determine organization + const channelData = useQuery(api.channels.get, () => ({ channelId })); + let messageContent = $state(""); let authorName = $state(""); + let attachedFiles = $state([]); + let showFileUploader = $state(false); $effect(() => { if (identity?.data && !authorName) { @@ -24,17 +40,23 @@ }); async function sendMessage() { - if (!messageContent.trim()) return; + if (!messageContent.trim() && attachedFiles.length === 0) return; + + const attachments = + attachedFiles.length > 0 ? attachedFiles.map((f) => f.id) : undefined; await sendMessageMutation.run({ channelId, - content: messageContent.trim(), + content: messageContent.trim() || "", author: authorName.trim() || "匿名", parentId: replyingTo?._id ?? undefined, + attachments, }); messageContent = ""; + attachedFiles = []; replyingTo = null; + showFileUploader = false; } function handleKeyPress(event: KeyboardEvent) { @@ -43,18 +65,56 @@ sendMessage(); } } + + function handleFilesUploaded(files: UploadedFile[]) { + attachedFiles = [...attachedFiles, ...files]; + showFileUploader = false; + } + + function removeAttachedFile(index: number) { + attachedFiles = attachedFiles.filter((_, i) => i !== index); + } + + function toggleFileUploader() { + showFileUploader = !showFileUploader; + } -
+
{#if replyingTo} -
+
返信先: {replyingTo.author} {replyingTo.content}
{/if} -
+ + {#if attachedFiles.length > 0} +
+

添付ファイル:

+
+ {#each attachedFiles as file, index} + removeAttachedFile(index)} + /> + {/each} +
+
+ {/if} + + + {#if showFileUploader && channelData?.data?.organizationId} + + {/if} + +
- +
+ + + +
+ +
+
+
+ + {#if sendMessageMutation.error} +
+ {sendMessageMutation.error} +
+ {/if}
diff --git a/packages/client/src/components/chat/MessageList.svelte b/packages/client/src/components/chat/MessageList.svelte index 214b1ca..09a88b7 100644 --- a/packages/client/src/components/chat/MessageList.svelte +++ b/packages/client/src/components/chat/MessageList.svelte @@ -3,6 +3,7 @@ import type { Doc } from "@packages/convex/src/convex/_generated/dataModel"; import { useQuery } from "convex-svelte"; import { onMount } from "svelte"; + import FileAttachment from "$components/files/FileAttachment.svelte"; import MessageDropdown from "./MessageDropdown.svelte"; interface Props { @@ -105,6 +106,15 @@
{message.content}
+ + + {#if message.attachments && message.attachments.length > 0} +
+ {#each message.attachments as fileId} + + {/each} +
+ {/if}
diff --git a/packages/client/src/components/files/FileAttachment.svelte b/packages/client/src/components/files/FileAttachment.svelte new file mode 100644 index 0000000..f6fbb33 --- /dev/null +++ b/packages/client/src/components/files/FileAttachment.svelte @@ -0,0 +1,224 @@ + + +{#if isLoading} + +
+
+
+
+
+
+
+{:else if file} +
+ {#if shouldShowImagePreview} + +
+ + + + {#if !compact} +
+
+ + {file.originalFilename} + +
+ {formatFileSize(file.size)} + {#if file.width && file.height} + {file.width}×{file.height} + {/if} +
+
+ +
+ {/if} +
+ {:else} + +
+
+
+
+ {getFileIcon(file.mimeType)} +
+
+ +
+
+ {file.originalFilename} +
+
+ {formatFileSize(file.size)} + {#if !compact} + {file.mimeType} + {/if} +
+
+ + +
+
+ {/if} +
+{:else} + +
+ ⚠️ + ファイルを読み込めませんでした +
+{/if} diff --git a/packages/client/src/components/files/FilePreview.svelte b/packages/client/src/components/files/FilePreview.svelte new file mode 100644 index 0000000..23755d4 --- /dev/null +++ b/packages/client/src/components/files/FilePreview.svelte @@ -0,0 +1,162 @@ + + +
+
+ + {#if isImage() && fileUrl()} +
+
+ {fileName()} +
+
+ {:else} + +
+ {getFileIcon(mimeType())} +
+ {/if} + + +
+
+ {fileName()} +
+
+ {fileSize} + {#if !compact} + {mimeType()} + {/if} +
+ + + {#if isImage() && "width" in file && file.width && file.height} +
+ {file.width} × {file.height}px +
+ {/if} +
+
+ + + {#if removable} + + {/if} +
diff --git a/packages/client/src/components/files/FileUploader.svelte b/packages/client/src/components/files/FileUploader.svelte new file mode 100644 index 0000000..8920ae2 --- /dev/null +++ b/packages/client/src/components/files/FileUploader.svelte @@ -0,0 +1,381 @@ + + +
+ + + + +
e.key === "Enter" && handleClick()} + > +
+
+ + + +
+

+ ファイルをドラッグ&ドロップ または クリックして選択 +

+

+ 最大{MAX_FILES}ファイル、{formatFileSize(MAX_FILE_SIZE)}まで +

+
+
+ + + {#if uploadsInProgress.length > 0} +
+
+

アップロード中...

+
+ {#each uploadsInProgress as upload, i} +
+
+ {upload.file.name} + ({formatFileSize(upload.file.size)}) + {#if upload.status === "error"} + + {/if} +
+ + {#if upload.status === "uploading"} + + {:else if upload.status === "completed"} +
完了
+ {:else if upload.status === "error"} +
{upload.error}
+ {/if} +
+ {/each} +
+
+
+ {/if} +
diff --git a/packages/convex/src/convex/files.ts b/packages/convex/src/convex/files.ts new file mode 100644 index 0000000..a456153 --- /dev/null +++ b/packages/convex/src/convex/files.ts @@ -0,0 +1,230 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; + +// ファイル権限チェック関数 +async function checkOrganizationMember( + ctx: QueryCtx, + organizationId: Id<"organizations">, + userId: Id<"users">, +) { + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => q.eq("organizationId", organizationId)) + .filter((q) => q.eq(q.field("userId"), userId)) + .first(); + + if (!membership) { + throw new Error("Organization のメンバーではありません"); + } + + return membership; +} + +async function checkOrganizationAdmin( + ctx: QueryCtx, + organizationId: Id<"organizations">, + userId: Id<"users">, +) { + const membership = await checkOrganizationMember(ctx, organizationId, userId); + + if (membership.permission !== "admin") { + throw new Error("管理者権限が必要です"); + } + + return membership; +} + +// ファイルのMIMEタイプを検証 +function isValidMimeType(mimeType: string): boolean { + const allowedTypes = [ + // 画像 + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/svg+xml", + // 文書 + "application/pdf", + "text/plain", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + // その他 + "application/json", + "text/csv", + ]; + + return allowedTypes.includes(mimeType); +} + +// ファイル名をサニタイズ +function sanitizeFilename(filename: string): string { + return filename + .replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF._-]/g, "_") + .substring(0, 255); +} + +/** + * アップロード用の署名付きURLを生成 + */ +export const generateUploadUrl = mutation({ + args: { + organizationId: v.id("organizations"), + }, + handler: async (ctx, { organizationId }) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("認証が必要です"); + + await checkOrganizationMember(ctx, organizationId, userId); + + return await ctx.storage.generateUploadUrl(); + }, +}); + +/** + * アップロード後のファイル情報をDBに保存 + */ +export const saveFileInfo = mutation({ + args: { + storageId: v.string(), + filename: v.string(), + originalFilename: v.string(), + mimeType: v.string(), + size: v.number(), + organizationId: v.id("organizations"), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("認証が必要です"); + + // ファイルサイズ制限チェック (10MB) + if (args.size > 10 * 1024 * 1024) { + throw new Error("ファイルサイズが大きすぎます(最大10MB)"); + } + + // MIMEタイプ検証 + if (!isValidMimeType(args.mimeType)) { + throw new Error("サポートされていないファイル形式です"); + } + + // Organization メンバーシップ確認 + await checkOrganizationMember(ctx, args.organizationId, userId); + + const sanitizedFilename = sanitizeFilename(args.filename); + + return await ctx.db.insert("files", { + ...args, + filename: sanitizedFilename, + uploadedBy: userId, + uploadedAt: Date.now(), + }); + }, +}); + +/** + * ファイルを削除 + */ +export const deleteFile = mutation({ + args: { fileId: v.id("files") }, + handler: async (ctx, { fileId }) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("認証が必要です"); + + const file = await ctx.db.get(fileId); + if (!file) throw new Error("ファイルが見つかりません"); + + // 権限チェック(アップロード者またはadmin) + if (file.uploadedBy !== userId) { + await checkOrganizationAdmin(ctx, file.organizationId, userId); + } + + // ストレージからファイルを削除 + await ctx.storage.delete(file.storageId); + + // データベースからレコードを削除 + await ctx.db.delete(fileId); + }, +}); + +/** + * ファイル情報とアクセスURLを取得 + */ +export const getFile = query({ + args: { fileId: v.id("files") }, + handler: async (ctx, { fileId }) => { + const file = await ctx.db.get(fileId); + if (!file) return null; + + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("認証が必要です"); + + await checkOrganizationMember(ctx, file.organizationId, userId); + + const url = await ctx.storage.getUrl(file.storageId); + return { ...file, url }; + }, +}); + +/** + * Organization内のファイル一覧を取得 + */ +export const listFiles = query({ + args: { + organizationId: v.id("organizations"), + limit: v.optional(v.number()), + }, + handler: async (ctx, { organizationId, limit = 50 }) => { + const userId = await getAuthUserId(ctx); + if (!userId) return []; + + await checkOrganizationMember(ctx, organizationId, userId); + + const files = await ctx.db + .query("files") + .withIndex("by_organization", (q) => + q.eq("organizationId", organizationId), + ) + .order("desc") + .take(limit); + + return await Promise.all( + files.map(async (file) => ({ + ...file, + url: await ctx.storage.getUrl(file.storageId), + })), + ); + }, +}); + +/** + * 複数ファイルの情報とURLを一括取得 + */ +export const getFiles = query({ + args: { fileIds: v.array(v.id("files")) }, + handler: async (ctx, { fileIds }) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("認証が必要です"); + + const results = []; + + for (const fileId of fileIds) { + const file = await ctx.db.get(fileId); + if (!file) continue; + + try { + await checkOrganizationMember(ctx, file.organizationId, userId); + const url = await ctx.storage.getUrl(file.storageId); + results.push({ ...file, url }); + } catch {} + } + + return results; + }, +}); diff --git a/packages/convex/src/convex/messages.ts b/packages/convex/src/convex/messages.ts index 0dbd242..5226949 100644 --- a/packages/convex/src/convex/messages.ts +++ b/packages/convex/src/convex/messages.ts @@ -25,6 +25,7 @@ export const send = mutation({ content: v.string(), author: v.string(), parentId: v.optional(v.id("messages")), + attachments: v.optional(v.array(v.id("files"))), }, handler: async (ctx, args) => { const perms = await getMessagePerms(ctx, { @@ -33,12 +34,29 @@ export const send = mutation({ if (!perms.create) { throw new Error("Insufficient permissions"); } + + // 添付ファイルがある場合、ファイルの存在と権限を確認 + if (args.attachments && args.attachments.length > 0) { + for (const fileId of args.attachments) { + const file = await ctx.db.get(fileId); + if (!file) { + throw new Error(`ファイルが見つかりません: ${fileId}`); + } + // ファイルがアップロードされた Organization とチャンネルの Organization が同じかチェック + const channel = await ctx.db.get(args.channelId); + if (!channel || file.organizationId !== channel.organizationId) { + throw new Error("不正な添付ファイルです"); + } + } + } + await ctx.db.insert("messages", { channelId: args.channelId, content: args.content, author: args.author, createdAt: Date.now(), parentId: args.parentId, + attachments: args.attachments, }); }, }); diff --git a/packages/convex/src/convex/schema.ts b/packages/convex/src/convex/schema.ts index e55f27c..d4391d2 100644 --- a/packages/convex/src/convex/schema.ts +++ b/packages/convex/src/convex/schema.ts @@ -39,6 +39,26 @@ export default defineSchema({ author: v.string(), createdAt: v.number(), parentId: v.optional(v.id("messages")), + // 添付ファイル + attachments: v.optional(v.array(v.id("files"))), }).index("by_channel", ["channelId"]), + files: defineTable({ + // Convex Storage ID + storageId: v.string(), + // ファイル情報 + filename: v.string(), + originalFilename: v.string(), + mimeType: v.string(), + size: v.number(), // bytes + // メタデータ + uploadedBy: v.id("users"), + uploadedAt: v.number(), + organizationId: v.id("organizations"), + // 画像の場合の追加情報 + width: v.optional(v.number()), + height: v.optional(v.number()), + }) + .index("by_organization", ["organizationId"]) + .index("by_uploader", ["uploadedBy"]), ...authTables, }); From 8f63338a004e452d20e6e5a18379c189d2d9caf3 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 8 Sep 2025 02:27:26 +0900 Subject: [PATCH 2/5] claude: add requirement of file length --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3ca5b50..81a302f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,3 +210,4 @@ Tauri conflicts with the web development server and requires more resources for - Name snippets with camelCase instead of PascalCase to avoid confusion with components. - Always run `bun check` after writing code. - Don't use style blocks in Svelte components, instead use TailwindCSS and DaisyUI. +- Prefer short files, 30 ~ 50 lines recommended, 100 lines MAX. From 23e1e6c5a4c54d710854523a8c7689b2d6983873 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:20:18 +0900 Subject: [PATCH 3/5] feat/file-upload: complete --- CLAUDE.md | 175 +++----- package.json | 2 +- .../client/src/components/app/ChatApp.svelte | 2 +- .../src/components/channels/Channel.svelte | 5 +- .../src/components/chat/MessageInput.svelte | 63 ++- .../src/components/chat/MessageList.svelte | 2 +- .../components/files/FileAttachment.svelte | 224 ---------- .../src/components/files/FilePreview.svelte | 162 -------- .../src/components/files/FileUploader.svelte | 381 ------------------ .../features/files/upload/FilePreview.svelte | 109 +++++ .../files/upload/FilePreview.svelte.ts | 73 ++++ .../src/features/files/upload/Selector.svelte | 35 ++ .../features/files/upload/Selector.svelte.ts | 48 +++ .../files/upload/SelectorDropzone.svelte | 67 +++ .../features/files/upload/uploader.svelte.ts | 150 +++++++ packages/client/src/features/files/utils.ts | 19 + .../features/files/view/FileAttachment.svelte | 140 +++++++ .../files/view/FileAttachment.svelte.ts | 46 +++ 18 files changed, 773 insertions(+), 930 deletions(-) delete mode 100644 packages/client/src/components/files/FileAttachment.svelte delete mode 100644 packages/client/src/components/files/FilePreview.svelte delete mode 100644 packages/client/src/components/files/FileUploader.svelte create mode 100644 packages/client/src/features/files/upload/FilePreview.svelte create mode 100644 packages/client/src/features/files/upload/FilePreview.svelte.ts create mode 100644 packages/client/src/features/files/upload/Selector.svelte create mode 100644 packages/client/src/features/files/upload/Selector.svelte.ts create mode 100644 packages/client/src/features/files/upload/SelectorDropzone.svelte create mode 100644 packages/client/src/features/files/upload/uploader.svelte.ts create mode 100644 packages/client/src/features/files/utils.ts create mode 100644 packages/client/src/features/files/view/FileAttachment.svelte create mode 100644 packages/client/src/features/files/view/FileAttachment.svelte.ts diff --git a/CLAUDE.md b/CLAUDE.md index 81a302f..ab42690 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,6 @@ This is a TypeScript monorepo using a Convex backend and SvelteKit frontend with - **ALWAYS use**: `$state`, `$derived`, `$effect` instead of legacy syntax - **Backend**: Convex (real-time database and functions) - **Desktop**: Tauri (optional, conflicts with web dev server) -- **Internationalization**: Paraglide for i18n (English/Japanese) - **Styling**: TailwindCSS v4 with DaisyUI components - **Package Manager**: Bun - **Monorepo Structure**: Workspaces with `packages/` @@ -23,75 +22,6 @@ This is a TypeScript monorepo using a Convex backend and SvelteKit frontend with - `packages/client/` - SvelteKit frontend with Tauri integration - `packages/convex/` - Convex backend with database schema and functions -## Development Commands - -### Setup - -```bash -bun install --frozen-lockfile -``` - -### Development Servers - -```bash -# Main development (Convex + Web Client) -bun dev - -# All development servers including Storybook -bun dev:all - -# Individual servers -bun dev:web # Web client at http://localhost:5173 -bun dev:convex # Convex at http://localhost:3210, Dashboard at http://localhost:6790 -bun dev:storybook # Storybook at http://localhost:6006 -bun dev:tauri # Desktop app (conflicts with web client) -``` - -### Building and Testing - -```bash -# Build all apps -bun run --filter=* build - -# Run tests -bun test - -# Type checking and linting -bun check # Runs lint + format + all app checks -bun check:lint # Biome linting only -bun check:format # Prettier formatting check only - -# Auto-fix -bun fix # Auto-fix lint + format issues -bun fix:lint # Biome auto-fix -bun fix:format # Prettier auto-format -``` - -### Convex Operations - -```bash -# Convex CLI commands -bun convex [command] - -# Code generation (run after schema changes) -bun sync -``` - -### Internationalization - -```bash -# Compile i18n messages -bun paraglide -``` - -## Code Architecture - -### Svelte Documentation - -When working with Svelte code, always reference the latest documentation: - -- **Latest Svelte Docs**: https://svelte.dev/llms-small.txt - ### Frontend (SvelteKit) - **Routes**: Standard SvelteKit routing in `packages/client/src/routes/` @@ -105,6 +35,21 @@ When working with Svelte code, always reference the latest documentation: - **Convex Integration**: Uses `convex-svelte` for reactive queries - **State Pattern**: Logic components (e.g., TaskList.svelte) separate from presentation (TaskListSkin.svelte) +### Backend (Convex) + +- **Schema**: Defined in `packages/convex/src/convex/schema.ts` +- **Functions**: Database operations in `packages/convex/src/convex/[feature].ts` +- **Type Safety**: Auto-generated types from schema shared with frontend via workspace dependency + +### Data Flow + +1. Convex schema defines database structure +2. Convex functions provide type-safe CRUD operations +3. Frontend uses `convex-svelte` hooks for reactive data +4. Automatic type generation ensures type safety across stack + +## Framework - Convex + ### Convex の Import について ```ts @@ -151,63 +96,51 @@ createOrganization.processing; // boolean, use for button disabled state / loadi createOrganization.error; // string | null, use for error messages ``` -### Backend (Convex) - -- **Schema**: Defined in `packages/convex/src/convex/schema.ts` -- **Functions**: Database operations in `packages/convex/src/convex/[feature].ts` -- **Type Safety**: Auto-generated types from schema shared with frontend via workspace dependency - -### Data Flow - -1. Convex schema defines database structure -2. Convex functions provide type-safe CRUD operations -3. Frontend uses `convex-svelte` hooks for reactive data -4. Automatic type generation ensures type safety across stack - -## Code Quality - -### Linting and Formatting +## Framework - Svelte -- **Biome**: Primary linter with strict rules -- **Prettier**: Code formatting (Biome formatter disabled) -- **Lefthook**: Pre-commit hooks for code generation and formatting +### Syntax -### Special Biome Rules +Never use logacy svelte syntax. This project uses Svelte 5 runes mode. -- Svelte files have relaxed rules for unused variables/imports -- Convex files exempt from import extension requirements -- Strict style rules including parameter assignment, const assertions +- ❌ FORBIDDEN: `$: reactiveVar = ...` (reactive statements) +- ❌ FORBIDDEN: `let count = 0` for reactive state +- ✅ REQUIRED: `let count = $state(0)` for reactive state +- ✅ REQUIRED: `$effect(() => { ... })` for side effects +- ✅ REQUIRED: `const sum = $derived(a + b);` for derived variables +- ✅ REQUIRED: `const sum = $derived.by(() => { if (a + b < 0) return 0; return a + b; );` for derived variables which needs a block. -### Pre-commit Hooks +### Svelte Capabilities -- Automatic code generation (`bun sync`) -- Automatic formatting (`bun fix:format`) +- clsx: Svelte has clsx builtin to its class. `
{text}
` -## Tauri Desktop App +- reactive class: Svelte allows defining reactive controller classes inside ".svelte.ts" files for reusability and separation of concerns. -Tauri integration requires separate development workflow: - -```bash -# Start backend first -bun dev:convex - -# Then start Tauri (in separate terminal) -bun dev:tauri +```ts +// my-controller.svelte.ts +class MyController { + foo = $state(3); + bar: number; + baz = $derived.by(() => bar + baz); // use derived.by if it needs to be lazy-initialized + doubleQux: number; + // unless it doesn't change at runtime (e.g. static configuration - initBar in this example), + // using getter function is better for reactivity. + constructor(initBar: number, props: () => { qux: number }) { + this.bar = $state(initBar); + this.doubleQux = $derived(props().qux * 2); + } +} ``` -Tauri conflicts with the web development server and requires more resources for compilation. - -## Coding Instructions - -- **🚫 NEVER USE LEGACY SVELTE SYNTAX**: This project uses Svelte 5 runes mode - - ❌ FORBIDDEN: `$: reactiveVar = ...` (reactive statements) - - ❌ FORBIDDEN: `let count = 0` for reactive state - - ✅ REQUIRED: `const reactiveVar = $derived(...)` - - ✅ REQUIRED: `let count = $state(0)` for reactive state - - ✅ REQUIRED: `$effect(() => { ... })` for side effects -- Always prefer using DaisyUI classes, and use minimal Tailwind classes. -- Separate components into smallest pieces for readability. -- Name snippets with camelCase instead of PascalCase to avoid confusion with components. -- Always run `bun check` after writing code. -- Don't use style blocks in Svelte components, instead use TailwindCSS and DaisyUI. -- Prefer short files, 30 ~ 50 lines recommended, 100 lines MAX. +## Code Quality / Coding Rules + +- NAMING: Name snippets with camelCase instead of PascalCase to avoid confusion with components. +- ALIAS: Use TypeScript import alias for client code. `import component from "~/features/foo/component.svelte";` +- CHECK: Always run `bun check` after writing code. +- STYLING: Don't use style blocks in Svelte components, instead use TailwindCSS and DaisyUI. +- STYLING: Always prefer using DaisyUI classes, and use minimal Tailwind classes. +- FILE LENGTH: Prefer short files, 30 ~ 50 lines recommended, 100 lines MAX. +- SEPARATE COMPONENTS: Separate components into smallest pieces for readability. +- SEPARATE LOGIC: Separate Logic from .svelte files into .svelte.ts files. + - .svelte.ts files should handle Calculation / Reactivity, while .svelte files should handle UI changes (e.g. navigation, modal open). + - if it has any reusable utility function, it should be separated again into plain .ts files / .svelte.ts + - An Ideal import tree would look like this: `UI component [.svelte] -> controller [.svelte.ts] -> processor [.svelte.ts] -> pure logic utility [.ts]` diff --git a/package.json b/package.json index d813b59..0e55884 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "dev": "bun run --filter='@packages/{client,convex}' dev", "dev:all": "(trap 'kill 0' EXIT; bun run dev:convex & bun run dev:web & bun run dev:storybook & wait", - "dev:web": "bun run --filter=@packages/client dev", + "dev:web": "cd packages/client; bun dev", "dev:convex": "cd packages/convex && bun run dev", "dev:tauri": "cd packages/client && bun run dev:tauri", "dev:storybook": "cd packages/client && bun run storybook", diff --git a/packages/client/src/components/app/ChatApp.svelte b/packages/client/src/components/app/ChatApp.svelte index 0b61ed0..2db66f0 100644 --- a/packages/client/src/components/app/ChatApp.svelte +++ b/packages/client/src/components/app/ChatApp.svelte @@ -85,7 +85,7 @@
{#if channelId} - + {:else}
diff --git a/packages/client/src/components/channels/Channel.svelte b/packages/client/src/components/channels/Channel.svelte index 9e1d4e4..9a13fc8 100644 --- a/packages/client/src/components/channels/Channel.svelte +++ b/packages/client/src/components/channels/Channel.svelte @@ -7,9 +7,10 @@ interface Props { selectedChannelId: Id<"channels">; + organizationId: Id<"organizations">; } - let { selectedChannelId }: Props = $props(); + let { selectedChannelId, organizationId }: Props = $props(); const selectedChannel = useQuery(api.channels.get, () => ({ channelId: selectedChannelId, @@ -30,4 +31,4 @@
- + diff --git a/packages/client/src/components/chat/MessageInput.svelte b/packages/client/src/components/chat/MessageInput.svelte index e7ce307..eaf8b2f 100644 --- a/packages/client/src/components/chat/MessageInput.svelte +++ b/packages/client/src/components/chat/MessageInput.svelte @@ -2,36 +2,26 @@ import { api, type Id } from "@packages/convex"; import type { Doc } from "@packages/convex/src/convex/_generated/dataModel"; import { useQuery } from "convex-svelte"; - import FilePreview from "$components/files/FilePreview.svelte"; - import FileUploader from "$components/files/FileUploader.svelte"; + import FilePreview from "~/features/files/upload/FilePreview.svelte"; + import FileSelector from "~/features/files/upload/Selector.svelte"; + import { FileUploader } from "~/features/files/upload/uploader.svelte"; import { useMutation } from "~/lib/useMutation.svelte.ts"; interface Props { + organizationId: Id<"organizations">; channelId: Id<"channels">; replyingTo: Doc<"messages"> | null; } - interface UploadedFile { - id: Id<"files">; - filename: string; - originalFilename: string; - mimeType: string; - size: number; - url?: string; - } - - let { channelId, replyingTo = $bindable() }: Props = $props(); + let { channelId, organizationId, replyingTo = $bindable() }: Props = $props(); const sendMessageMutation = useMutation(api.messages.send); const identity = useQuery(api.users.me, {}); - // Get channel info to determine organization - const channelData = useQuery(api.channels.get, () => ({ channelId })); - let messageContent = $state(""); let authorName = $state(""); - let attachedFiles = $state([]); - let showFileUploader = $state(false); + let showFileSelector = $state(false); + let attachedFiles = $state([]); $effect(() => { if (identity?.data && !authorName) { @@ -39,11 +29,16 @@ } }); + const uploader = new FileUploader(() => ({ + organizationId, + })); + async function sendMessage() { if (!messageContent.trim() && attachedFiles.length === 0) return; - const attachments = - attachedFiles.length > 0 ? attachedFiles.map((f) => f.id) : undefined; + const attachments = (await uploader.uploadAll(attachedFiles)).map( + (it) => it.id, + ); await sendMessageMutation.run({ channelId, @@ -56,7 +51,7 @@ messageContent = ""; attachedFiles = []; replyingTo = null; - showFileUploader = false; + showFileSelector = false; } function handleKeyPress(event: KeyboardEvent) { @@ -66,17 +61,8 @@ } } - function handleFilesUploaded(files: UploadedFile[]) { - attachedFiles = [...attachedFiles, ...files]; - showFileUploader = false; - } - - function removeAttachedFile(index: number) { - attachedFiles = attachedFiles.filter((_, i) => i !== index); - } - function toggleFileUploader() { - showFileUploader = !showFileUploader; + showFileSelector = !showFileSelector; } @@ -94,12 +80,12 @@

添付ファイル:

- {#each attachedFiles as file, index} + {#each attachedFiles as file, index (file.name)} removeAttachedFile(index)} + onRemove={() => attachedFiles.splice(index, 1)} /> {/each}
@@ -107,10 +93,13 @@ {/if} - {#if showFileUploader && channelData?.data?.organizationId} - { + showFileSelector = false; + }} /> {/if} @@ -154,7 +143,7 @@ d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" > - {showFileUploader ? "キャンセル" : "ファイル添付"} + {showFileSelector ? "キャンセル" : "ファイル添付"}
diff --git a/packages/client/src/components/chat/MessageList.svelte b/packages/client/src/components/chat/MessageList.svelte index 09a88b7..264d426 100644 --- a/packages/client/src/components/chat/MessageList.svelte +++ b/packages/client/src/components/chat/MessageList.svelte @@ -3,7 +3,7 @@ import type { Doc } from "@packages/convex/src/convex/_generated/dataModel"; import { useQuery } from "convex-svelte"; import { onMount } from "svelte"; - import FileAttachment from "$components/files/FileAttachment.svelte"; + import FileAttachment from "../../features/files/view/FileAttachment.svelte"; import MessageDropdown from "./MessageDropdown.svelte"; interface Props { diff --git a/packages/client/src/components/files/FileAttachment.svelte b/packages/client/src/components/files/FileAttachment.svelte deleted file mode 100644 index f6fbb33..0000000 --- a/packages/client/src/components/files/FileAttachment.svelte +++ /dev/null @@ -1,224 +0,0 @@ - - -{#if isLoading} - -
-
-
-
-
-
-
-{:else if file} -
- {#if shouldShowImagePreview} - -
- - - - {#if !compact} -
-
- - {file.originalFilename} - -
- {formatFileSize(file.size)} - {#if file.width && file.height} - {file.width}×{file.height} - {/if} -
-
- -
- {/if} -
- {:else} - -
-
-
-
- {getFileIcon(file.mimeType)} -
-
- -
-
- {file.originalFilename} -
-
- {formatFileSize(file.size)} - {#if !compact} - {file.mimeType} - {/if} -
-
- - -
-
- {/if} -
-{:else} - -
- ⚠️ - ファイルを読み込めませんでした -
-{/if} diff --git a/packages/client/src/components/files/FilePreview.svelte b/packages/client/src/components/files/FilePreview.svelte deleted file mode 100644 index 23755d4..0000000 --- a/packages/client/src/components/files/FilePreview.svelte +++ /dev/null @@ -1,162 +0,0 @@ - - -
-
- - {#if isImage() && fileUrl()} -
-
- {fileName()} -
-
- {:else} - -
- {getFileIcon(mimeType())} -
- {/if} - - -
-
- {fileName()} -
-
- {fileSize} - {#if !compact} - {mimeType()} - {/if} -
- - - {#if isImage() && "width" in file && file.width && file.height} -
- {file.width} × {file.height}px -
- {/if} -
-
- - - {#if removable} - - {/if} -
diff --git a/packages/client/src/components/files/FileUploader.svelte b/packages/client/src/components/files/FileUploader.svelte deleted file mode 100644 index 8920ae2..0000000 --- a/packages/client/src/components/files/FileUploader.svelte +++ /dev/null @@ -1,381 +0,0 @@ - - -
- - - - -
e.key === "Enter" && handleClick()} - > -
-
- - - -
-

- ファイルをドラッグ&ドロップ または クリックして選択 -

-

- 最大{MAX_FILES}ファイル、{formatFileSize(MAX_FILE_SIZE)}まで -

-
-
- - - {#if uploadsInProgress.length > 0} -
-
-

アップロード中...

-
- {#each uploadsInProgress as upload, i} -
-
- {upload.file.name} - ({formatFileSize(upload.file.size)}) - {#if upload.status === "error"} - - {/if} -
- - {#if upload.status === "uploading"} - - {:else if upload.status === "completed"} -
完了
- {:else if upload.status === "error"} -
{upload.error}
- {/if} -
- {/each} -
-
-
- {/if} -
diff --git a/packages/client/src/features/files/upload/FilePreview.svelte b/packages/client/src/features/files/upload/FilePreview.svelte new file mode 100644 index 0000000..6082291 --- /dev/null +++ b/packages/client/src/features/files/upload/FilePreview.svelte @@ -0,0 +1,109 @@ + + +
+
+ {#if controller.isImage && controller.fileUrl} +
+
+ {controller.fileName} +
+
+ {:else} +
+ {getFileIcon(controller.mimeType)} +
+ {/if} + +
+
+ {controller.fileName} +
+
+ {controller.fileSize} + {#if !controller.compact} + {controller.mimeType} + {/if} +
+
+
+ + {#if controller.removable} + + {/if} +
diff --git a/packages/client/src/features/files/upload/FilePreview.svelte.ts b/packages/client/src/features/files/upload/FilePreview.svelte.ts new file mode 100644 index 0000000..0ff71cd --- /dev/null +++ b/packages/client/src/features/files/upload/FilePreview.svelte.ts @@ -0,0 +1,73 @@ +import type { UploadProgress } from "./uploader.svelte.ts"; + +export interface FileInfo { + filename: string; + originalFilename: string; + mimeType: string; + size: number; + url?: string; + width?: number; + height?: number; +} + +export interface FilePreviewProps { + file: File | UploadProgress; + removable?: boolean; + compact?: boolean; + onRemove?: () => void; +} + +export class FilePreviewController { + file: File; + removable: boolean; + compact: boolean; + onRemove?: () => void; + + isImage = $derived.by(() => this.mimeType.startsWith("image/")); + + fileSize = $derived.by(() => { + const bytes = this.file.size; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }); + fileName = $derived.by(() => this.file.name); + fileUrl = $state(null); + mimeType = $derived.by(() => this.file.type); + progress = $derived.by(() => { + const progress = this.props().file; + if ("file" in progress) { + return progress; + } + return undefined; + }); + + constructor(private props: () => FilePreviewProps) { + this.file = $derived.by(() => { + const f = props().file; + if ("file" in f) { + return f.file; + } else { + return f; + } + }); + this.removable = $derived(props().removable ?? false); + this.compact = $derived(props().compact ?? false); + this.onRemove = $derived(props().onRemove); + + $effect(() => { + console.log("file", this.file); + if (this.file instanceof File) { + const url = URL.createObjectURL(this.file); + this.fileUrl = url; + return () => { + URL.revokeObjectURL(url); + }; + } + }); + } + + handleRemove = () => { + this.onRemove?.(); + }; +} diff --git a/packages/client/src/features/files/upload/Selector.svelte b/packages/client/src/features/files/upload/Selector.svelte new file mode 100644 index 0000000..b9e64fc --- /dev/null +++ b/packages/client/src/features/files/upload/Selector.svelte @@ -0,0 +1,35 @@ + + +
+ +
diff --git a/packages/client/src/features/files/upload/Selector.svelte.ts b/packages/client/src/features/files/upload/Selector.svelte.ts new file mode 100644 index 0000000..b408189 --- /dev/null +++ b/packages/client/src/features/files/upload/Selector.svelte.ts @@ -0,0 +1,48 @@ +import type { Id } from "@packages/convex"; + +export interface SelectorProps { + organizationId: Id<"organizations">; + onselect: (files: File[]) => void; + multiple?: boolean; +} + +export class SelectorController { + organizationId = $derived.by(() => this.props().organizationId); + multiple = $derived.by(() => this.props().multiple ?? true); + onselect = $derived.by(() => this.props().onselect); + fileInput = $state(); + + isDragOver = $state(false); // what is this used for? + + constructor(private props: () => SelectorProps) {} + handleDragOver = (event: DragEvent) => { + event.preventDefault(); + this.isDragOver = true; + }; + + handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + this.isDragOver = false; + }; + + handleDrop = (event: DragEvent) => { + event.preventDefault(); + this.isDragOver = false; + + const files = Array.from(event.dataTransfer?.files || []); + this.onselect(files); + }; + + handleFileSelect = (event: Event) => { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files || []); + if (files.length > 0) { + this.onselect(files); + } + input.value = ""; + }; + + handleClick = () => { + this.fileInput?.click(); + }; +} diff --git a/packages/client/src/features/files/upload/SelectorDropzone.svelte b/packages/client/src/features/files/upload/SelectorDropzone.svelte new file mode 100644 index 0000000..5eac810 --- /dev/null +++ b/packages/client/src/features/files/upload/SelectorDropzone.svelte @@ -0,0 +1,67 @@ + + +
+ + +
e.key === "Enter" && controller.handleClick()} + > +
+
+ + + +
+

+ ファイルをドラッグ&ドロップ または クリックして選択 +

+

+ 最大{MAX_FILES}ファイル、{formatFileSize(MAX_FILE_SIZE)}まで +

+
+
+
diff --git a/packages/client/src/features/files/upload/uploader.svelte.ts b/packages/client/src/features/files/upload/uploader.svelte.ts new file mode 100644 index 0000000..f4e978f --- /dev/null +++ b/packages/client/src/features/files/upload/uploader.svelte.ts @@ -0,0 +1,150 @@ +import { api, type Id } from "@packages/convex"; +import { useMutation } from "~/lib/useMutation.svelte"; + +export const MAX_FILES = 10; +// constants +export const MAX_FILE_SIZE = 30 * 1024 * 1024; // 10MB +export const ALLOWED_TYPES = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/svg+xml", + "application/pdf", + "text/plain", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/json", + "text/csv", +]; + +export interface UploadedFile { + id: Id<"files">; + filename: string; + originalFilename: string; + mimeType: string; + size: number; + url?: string; +} + +export interface UploadProgress { + file: File; + status: "queued" | "uploading" | "completed" | "error"; + error?: Error; + result?: UploadedFile; +} + +export class FileUploader { + private saveFileInfo = useMutation(api.files.saveFileInfo); + private generateUploadUrl = useMutation(api.files.generateUploadUrl); + private organizationId: Id<"organizations">; + uploading = $state(false); + progress: UploadProgress[] = $state([]); + + constructor( + props: () => { + organizationId: Id<"organizations">; + }, + ) { + this.organizationId = $derived(props().organizationId); + } + + async uploadAll(files: File[]) { + this.uploading = true; + this.progress = files.map((f) => ({ + file: f, + status: "queued", + })); + const uploaded: UploadedFile[] = []; + for (const p of this.progress) { + try { + const result = await this.uploadFile(p.file); + p.result = result; + p.status = "completed"; + uploaded.push(result); + } catch (err) { + p.error = new Error("Failed to upload", { + cause: err, + }); + p.status = "error"; + } + } + this.uploading = false; + return uploaded; + } + + private async uploadFile(file: File): Promise { + const uploadUrl = await this.generateUploadUrl.run({ + organizationId: this.organizationId, + }); + if (!uploadUrl) { + throw new Error("アップロードURLの取得に失敗しました"); + } + + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + + if (!response.ok) { + throw new Error("アップロードに失敗しました"); + } + + const { storageId } = await response.json(); + + const fileId = await this.saveFileInfo.run({ + storageId, + filename: file.name, + originalFilename: file.name, + mimeType: file.type, + size: file.size, + organizationId: this.organizationId, + }); + + if (!fileId) { + throw new Error("ファイル情報の保存に失敗しました"); + } + + return { + id: fileId, + filename: file.name, + originalFilename: file.name, + mimeType: file.type, + size: file.size, + }; + } +} + +export function validate(...files: File[]) { + const valid: File[] = []; + const errors: Error[] = []; + + if (files.length > MAX_FILES) { + errors.push(new Error(`最大${MAX_FILES}ファイルまでアップロード可能です`)); + return { valid: [], errors }; + } + + for (const file of files) { + if (file.size > MAX_FILE_SIZE) { + errors.push( + new Error(`${file.name}: ファイルサイズが大きすぎます(最大10MB)`), + ); + continue; + } + + if (!ALLOWED_TYPES.includes(file.type)) { + errors.push( + new Error(`${file.name}: サポートされていないファイル形式です`), + ); + continue; + } + + valid.push(file); + } + + return { valid, errors }; +} diff --git a/packages/client/src/features/files/utils.ts b/packages/client/src/features/files/utils.ts new file mode 100644 index 0000000..5860b0a --- /dev/null +++ b/packages/client/src/features/files/utils.ts @@ -0,0 +1,19 @@ +export function isImage(mime?: string): boolean { + if (!mime) return false; + return mime.startsWith("image/"); +} + +export function getFileIcon(mimeType: string): string { + if (mimeType.startsWith("image/")) return "🖼️"; + if (mimeType === "application/pdf") return "📄"; + if (mimeType.startsWith("text/")) return "📝"; + if (mimeType.includes("word") || mimeType.includes("document")) return "📄"; + if (mimeType.includes("excel") || mimeType.includes("sheet")) return "📊"; + return "📎"; +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/packages/client/src/features/files/view/FileAttachment.svelte b/packages/client/src/features/files/view/FileAttachment.svelte new file mode 100644 index 0000000..29bfba5 --- /dev/null +++ b/packages/client/src/features/files/view/FileAttachment.svelte @@ -0,0 +1,140 @@ + + +{#if controller.isLoading} +
+
+
+
+
+
+
+{:else if controller.fileData} +
+ {#if controller.shouldShowImagePreview} +
+ +
+ {:else} +
+
+
+
+ + {getFileIcon(controller.fileData.mimeType)} + +
+
+
+
+ {controller.fileData.originalFilename} +
+
+ {formatFileSize(controller.fileData.size)} +
+
+ +
+
+ {/if} +
+{:else} +
+ ⚠️ + ファイルを読み込めませんでした +
+{/if} diff --git a/packages/client/src/features/files/view/FileAttachment.svelte.ts b/packages/client/src/features/files/view/FileAttachment.svelte.ts new file mode 100644 index 0000000..4c690a2 --- /dev/null +++ b/packages/client/src/features/files/view/FileAttachment.svelte.ts @@ -0,0 +1,46 @@ +import { api, type Id } from "@packages/convex"; +import { useQuery } from "convex-svelte"; +import { isImage } from "../utils.ts"; + +export interface FileAttachmentProps { + fileId: Id<"files">; + compact?: boolean; + showPreview?: boolean; +} + +export class FileAttachmentController { + fileId: Id<"files">; + compact: boolean; + showPreview: boolean; + + file = $derived(useQuery(api.files.getFile, () => ({ fileId: this.fileId }))); + fileData = $derived(this.file?.data); + isLoading = $derived(this.file?.isLoading ?? true); + isImage = $derived(isImage(this.fileData?.mimeType)); + shouldShowImagePreview = $derived.by( + () => this.showPreview && this.isImage && !!this.fileData?.url, + ); + + constructor(props: () => FileAttachmentProps) { + this.fileId = $derived(props().fileId); + this.compact = $derived(props().compact ?? false); + this.showPreview = $derived(props().showPreview ?? true); + } + + handleDownload = () => { + if (!this.fileData?.url) return; + + const link = document.createElement("a"); + link.href = this.fileData.url; + link.download = this.fileData.originalFilename || this.fileData.filename; + link.target = "_blank"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + handleImageClick = () => { + if (!this.fileData?.url || !this.isImage) return; + window.open(this.fileData.url, "_blank"); + }; +} From d319d14a44fb5e0de2fe212399378afdafc95afa Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:23:15 +0900 Subject: [PATCH 4/5] fix lint --- packages/client/src/app.html | 22 +++++++++---------- .../components/example/TaskListSkin.svelte | 4 +--- packages/client/src/hooks.server.ts | 1 + 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/client/src/app.html b/packages/client/src/app.html index 3b193fc..c808a7f 100644 --- a/packages/client/src/app.html +++ b/packages/client/src/app.html @@ -1,15 +1,13 @@ + + + + + %sveltekit.head% + - - - - - %sveltekit.head% - - - -
%sveltekit.body%
- - - \ No newline at end of file + +
%sveltekit.body%
+ + diff --git a/packages/client/src/components/example/TaskListSkin.svelte b/packages/client/src/components/example/TaskListSkin.svelte index 4207e30..933aab9 100644 --- a/packages/client/src/components/example/TaskListSkin.svelte +++ b/packages/client/src/components/example/TaskListSkin.svelte @@ -62,7 +62,5 @@ {/each} {/if} - +
diff --git a/packages/client/src/hooks.server.ts b/packages/client/src/hooks.server.ts index e913ae5..0ad828c 100644 --- a/packages/client/src/hooks.server.ts +++ b/packages/client/src/hooks.server.ts @@ -2,6 +2,7 @@ import { createConvexAuthHooks } from "@mmailaender/convex-auth-svelte/sveltekit import type { Handle } from "@sveltejs/kit"; import { sequence } from "@sveltejs/kit/hooks"; import { PUBLIC_CONVEX_URL } from "$lib/env"; + // import { paraglideMiddleware } from "$lib/paraglide/server"; // const handleParaglide: Handle = ({ event, resolve }) => From c0565fb55198d4ceb44fa1312252e8cbcc9db941 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:03:19 +0900 Subject: [PATCH 5/5] final patch --- CLAUDE.md | 14 +- docs/file-upload/implement.md | 190 +------------------------ packages/convex/src/convex/files.ts | 88 +++--------- packages/convex/src/convex/messages.ts | 16 +-- packages/convex/src/convex/perms.ts | 88 ++++++++++++ 5 files changed, 124 insertions(+), 272 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab42690..7ae7708 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,14 +133,24 @@ class MyController { ## Code Quality / Coding Rules +### Common Rules + +- FILE LENGTH: Prefer short files, 30 ~ 50 lines recommended, 100 lines MAX. +- CHECK: Always run `bun check` after writing code. +- DOCUMENTATION: document the behavior (and optionally the expected usage) of the code, not the implementation + +### Svelte + - NAMING: Name snippets with camelCase instead of PascalCase to avoid confusion with components. - ALIAS: Use TypeScript import alias for client code. `import component from "~/features/foo/component.svelte";` -- CHECK: Always run `bun check` after writing code. - STYLING: Don't use style blocks in Svelte components, instead use TailwindCSS and DaisyUI. - STYLING: Always prefer using DaisyUI classes, and use minimal Tailwind classes. -- FILE LENGTH: Prefer short files, 30 ~ 50 lines recommended, 100 lines MAX. - SEPARATE COMPONENTS: Separate components into smallest pieces for readability. - SEPARATE LOGIC: Separate Logic from .svelte files into .svelte.ts files. - .svelte.ts files should handle Calculation / Reactivity, while .svelte files should handle UI changes (e.g. navigation, modal open). - if it has any reusable utility function, it should be separated again into plain .ts files / .svelte.ts - An Ideal import tree would look like this: `UI component [.svelte] -> controller [.svelte.ts] -> processor [.svelte.ts] -> pure logic utility [.ts]` + +### Convex Rules + +- AUTHORIZATION: write authorization determinator in `packages/convex/src/convex/perms.ts` diff --git a/docs/file-upload/implement.md b/docs/file-upload/implement.md index 86337ba..17ef079 100644 --- a/docs/file-upload/implement.md +++ b/docs/file-upload/implement.md @@ -81,156 +81,29 @@ messages: defineTable({ アップロード用の署名付きURLを生成します。 -```typescript -generateUploadUrl: mutation({ - args: { - organizationId: v.id("organizations"), - }, - handler: async (ctx, { organizationId }) => { - // 認証・権限チェック - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("認証が必要です"); - - await checkOrganizationMember(ctx, organizationId, identity.subject); - - return await ctx.storage.generateUploadUrl(); - }, -}); -``` - #### `saveFileInfo` アップロード後のファイル情報をデータベースに保存します。 -```typescript -saveFileInfo: mutation({ - args: { - storageId: v.string(), - filename: v.string(), - originalFilename: v.string(), - mimeType: v.string(), - size: v.number(), - organizationId: v.id("organizations"), - width: v.optional(v.number()), - height: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("認証が必要です"); - - // ファイルサイズ制限チェック - if (args.size > 10 * 1024 * 1024) { - // 10MB - throw new Error("ファイルサイズが大きすぎます(最大10MB)"); - } - - return await ctx.db.insert("files", { - ...args, - uploadedBy: identity.subject, - uploadedAt: Date.now(), - }); - }, -}); -``` - #### `deleteFile` ファイルを削除します。 -```typescript -deleteFile: mutation({ - args: { fileId: v.id("files") }, - handler: async (ctx, { fileId }) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) throw new Error("認証が必要です"); - - const file = await ctx.db.get(fileId); - if (!file) throw new Error("ファイルが見つかりません"); - - // 権限チェック(アップロード者またはadmin) - if (file.uploadedBy !== identity.subject) { - await checkOrganizationAdmin(ctx, file.organizationId, identity.subject); - } - - // ストレージからファイルを削除 - await ctx.storage.delete(file.storageId); - - // データベースからレコードを削除 - await ctx.db.delete(fileId); - }, -}); -``` - ### Queries #### `getFile` ファイル情報とアクセスURLを取得します。 -```typescript -getFile: query({ - args: { fileId: v.id("files") }, - handler: async (ctx, { fileId }) => { - const file = await ctx.db.get(fileId); - if (!file) return null; - - const identity = await ctx.auth.getUserIdentity(); - if (identity) { - await checkOrganizationMember(ctx, file.organizationId, identity.subject); - } - - const url = await ctx.storage.getUrl(file.storageId); - return { ...file, url }; - }, -}); -``` - #### `listFiles` Organization内のファイル一覧を取得します。 -```typescript -listFiles: query({ - args: { - organizationId: v.id("organizations"), - limit: v.optional(v.number()), - }, - handler: async (ctx, { organizationId, limit = 50 }) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) return []; - - await checkOrganizationMember(ctx, organizationId, identity.subject); - - const files = await ctx.db - .query("files") - .withIndex("by_organization", (q) => - q.eq("organizationId", organizationId), - ) - .order("desc") - .take(limit); - - return Promise.all( - files.map(async (file) => ({ - ...file, - url: await ctx.storage.getUrl(file.storageId), - })), - ); - }, -}); -``` - ## フロントエンド設計 ### コンポーネント構成 -#### 1. FileUploader.svelte - -メインのファイルアップロードコンポーネント - -**Props:** - -- `organizationId: string` - アップロード先のOrganization ID -- `onUpload?: (files: FileInfo[]) => void` - アップロード完了時のコールバック +#### 1. ファイルアップロードコンポーネント **Features:** @@ -240,15 +113,7 @@ listFiles: query({ - アップロード進捗表示 - バリデーション(サイズ・形式) -#### 2. FilePreview.svelte - -ファイルのプレビュー表示コンポーネント - -**Props:** - -- `file: File | FileInfo` - プレビューするファイル -- `removable?: boolean` - 削除ボタンの表示制御 -- `onRemove?: () => void` - 削除時のコールバック +#### 2. ファイルのプレビュー表示コンポーネント **Features:** @@ -256,14 +121,7 @@ listFiles: query({ - ファイル情報表示(名前・サイズ・形式) - 削除ボタン -#### 3. FileAttachment.svelte - -メッセージ内の添付ファイル表示コンポーネント - -**Props:** - -- `fileId: string` - ファイルID -- `compact?: boolean` - コンパクト表示モード +#### 3. メッセージ内の添付ファイル表示コンポーネント **Features:** @@ -271,9 +129,7 @@ listFiles: query({ - ダウンロードリンク - 画像のインラインプレビュー -#### 4. MessageInput.svelte (拡張) - -既存のメッセージ入力コンポーネントを拡張 +#### 4. 既存のメッセージ入力コンポーネントを拡張 **追加Features:** @@ -327,50 +183,12 @@ listFiles: query({ ## パフォーマンス最適化 -### アップロード最適化 - -- **並行アップロード**: 複数ファイルの並行処理 -- **チャンク分割**: 大ファイルの分割アップロード(将来実装) -- **レジューム**: 中断されたアップロードの再開(将来実装) - ### 表示最適化 - **遅延読み込み**: 画像の lazy loading - **サムネイル**: 小さいプレビュー画像生成(将来実装) - **キャッシュ**: ファイルURLのキャッシュ -## 実装順序 - -### Phase 1: 基盤整備 - -- [ ] データベーススキーマ更新 -- [ ] 基本的なConvex API実装 -- [ ] 権限チェック関数の実装 - -### Phase 2: ファイルアップロード - -- [ ] FileUploader コンポーネント実装 -- [ ] ドラッグ&ドロップ機能 -- [ ] アップロード進捗表示 - -### Phase 3: プレビュー・表示 - -- [ ] FilePreview コンポーネント実装 -- [ ] FileAttachment コンポーネント実装 -- [ ] 画像プレビュー機能 - -### Phase 4: メッセージ統合 - -- [ ] MessageInput コンポーネント拡張 -- [ ] 添付ファイル付きメッセージ機能 -- [ ] MessageList での添付ファイル表示 - -### Phase 5: 最適化・テスト - -- [ ] エラーハンドリング強化 -- [ ] パフォーマンス最適化 -- [ ] E2Eテスト実装 - ## 将来の拡張予定 - **ファイル管理画面**: Organization内のファイル管理機能 diff --git a/packages/convex/src/convex/files.ts b/packages/convex/src/convex/files.ts index a456153..7a2d209 100644 --- a/packages/convex/src/convex/files.ts +++ b/packages/convex/src/convex/files.ts @@ -1,41 +1,6 @@ -import { getAuthUserId } from "@convex-dev/auth/server"; import { v } from "convex/values"; -import type { Id } from "./_generated/dataModel"; -import type { QueryCtx } from "./_generated/server"; import { mutation, query } from "./_generated/server"; - -// ファイル権限チェック関数 -async function checkOrganizationMember( - ctx: QueryCtx, - organizationId: Id<"organizations">, - userId: Id<"users">, -) { - const membership = await ctx.db - .query("organizationMembers") - .withIndex("by_organization", (q) => q.eq("organizationId", organizationId)) - .filter((q) => q.eq(q.field("userId"), userId)) - .first(); - - if (!membership) { - throw new Error("Organization のメンバーではありません"); - } - - return membership; -} - -async function checkOrganizationAdmin( - ctx: QueryCtx, - organizationId: Id<"organizations">, - userId: Id<"users">, -) { - const membership = await checkOrganizationMember(ctx, organizationId, userId); - - if (membership.permission !== "admin") { - throw new Error("管理者権限が必要です"); - } - - return membership; -} +import { getFilePerms } from "./perms"; // ファイルのMIMEタイプを検証 function isValidMimeType(mimeType: string): boolean { @@ -77,11 +42,7 @@ export const generateUploadUrl = mutation({ organizationId: v.id("organizations"), }, handler: async (ctx, { organizationId }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("認証が必要です"); - - await checkOrganizationMember(ctx, organizationId, userId); - + await getFilePerms(ctx, { organizationId }); return await ctx.storage.generateUploadUrl(); }, }); @@ -101,8 +62,9 @@ export const saveFileInfo = mutation({ height: v.optional(v.number()), }, handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("認証が必要です"); + const { userId } = await getFilePerms(ctx, { + organizationId: args.organizationId, + }); // ファイルサイズ制限チェック (10MB) if (args.size > 10 * 1024 * 1024) { @@ -114,9 +76,6 @@ export const saveFileInfo = mutation({ throw new Error("サポートされていないファイル形式です"); } - // Organization メンバーシップ確認 - await checkOrganizationMember(ctx, args.organizationId, userId); - const sanitizedFilename = sanitizeFilename(args.filename); return await ctx.db.insert("files", { @@ -134,17 +93,14 @@ export const saveFileInfo = mutation({ export const deleteFile = mutation({ args: { fileId: v.id("files") }, handler: async (ctx, { fileId }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("認証が必要です"); - - const file = await ctx.db.get(fileId); - if (!file) throw new Error("ファイルが見つかりません"); + const perms = await getFilePerms(ctx, { fileId }); - // 権限チェック(アップロード者またはadmin) - if (file.uploadedBy !== userId) { - await checkOrganizationAdmin(ctx, file.organizationId, userId); + if (!perms.delete || !perms.file) { + throw new Error("ファイルを削除する権限がありません"); } + const file = perms.file; + // ストレージからファイルを削除 await ctx.storage.delete(file.storageId); @@ -159,14 +115,10 @@ export const deleteFile = mutation({ export const getFile = query({ args: { fileId: v.id("files") }, handler: async (ctx, { fileId }) => { - const file = await ctx.db.get(fileId); + const perms = await getFilePerms(ctx, { fileId }); + const file = perms.file; if (!file) return null; - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("認証が必要です"); - - await checkOrganizationMember(ctx, file.organizationId, userId); - const url = await ctx.storage.getUrl(file.storageId); return { ...file, url }; }, @@ -181,10 +133,7 @@ export const listFiles = query({ limit: v.optional(v.number()), }, handler: async (ctx, { organizationId, limit = 50 }) => { - const userId = await getAuthUserId(ctx); - if (!userId) return []; - - await checkOrganizationMember(ctx, organizationId, userId); + await getFilePerms(ctx, { organizationId }); const files = await ctx.db .query("files") @@ -209,17 +158,14 @@ export const listFiles = query({ export const getFiles = query({ args: { fileIds: v.array(v.id("files")) }, handler: async (ctx, { fileIds }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("認証が必要です"); - const results = []; for (const fileId of fileIds) { - const file = await ctx.db.get(fileId); - if (!file) continue; - try { - await checkOrganizationMember(ctx, file.organizationId, userId); + const perms = await getFilePerms(ctx, { fileId }); + const file = perms.file; + if (!file) continue; + const url = await ctx.storage.getUrl(file.storageId); results.push({ ...file, url }); } catch {} diff --git a/packages/convex/src/convex/messages.ts b/packages/convex/src/convex/messages.ts index 5226949..69aef3b 100644 --- a/packages/convex/src/convex/messages.ts +++ b/packages/convex/src/convex/messages.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; -import { getMessagePerms } from "./perms"; +import { getMessagePerms, validateFileAttachments } from "./perms"; export const list = query({ args: { channelId: v.id("channels") }, @@ -35,19 +35,9 @@ export const send = mutation({ throw new Error("Insufficient permissions"); } - // 添付ファイルがある場合、ファイルの存在と権限を確認 + // 添付ファイルの検証 if (args.attachments && args.attachments.length > 0) { - for (const fileId of args.attachments) { - const file = await ctx.db.get(fileId); - if (!file) { - throw new Error(`ファイルが見つかりません: ${fileId}`); - } - // ファイルがアップロードされた Organization とチャンネルの Organization が同じかチェック - const channel = await ctx.db.get(args.channelId); - if (!channel || file.organizationId !== channel.organizationId) { - throw new Error("不正な添付ファイルです"); - } - } + await validateFileAttachments(ctx, args.attachments, args.channelId); } await ctx.db.insert("messages", { diff --git a/packages/convex/src/convex/perms.ts b/packages/convex/src/convex/perms.ts index b6ee6a7..c9cac08 100644 --- a/packages/convex/src/convex/perms.ts +++ b/packages/convex/src/convex/perms.ts @@ -176,3 +176,91 @@ export async function getOrganizationPerms( }, }; } + +/** +# Files + +- Fellow can upload files to the organization. +- Fellow can view files in the organization. +- File uploader and admin can delete files. +- Attachments must belong to the same organization as the channel. + +*/ +export async function getFilePerms( + ctx: QueryCtx, + query: + | { + fileId: Id<"files">; + } + | { + organizationId: Id<"organizations">; + }, +) { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User is not authenticated"); + } + + const { file, organizationId } = await (async () => { + if ("fileId" in query) { + const file = await ctx.db.get(query.fileId); + if (!file) { + throw new Error("File not found"); + } + return { file, organizationId: file.organizationId }; + } else { + return { file: null, organizationId: query.organizationId }; + } + })(); + + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_organization", (q) => q.eq("organizationId", organizationId)) + .filter((q) => q.eq(q.field("userId"), userId)) + .first(); + + if (!membership) { + throw new Error("User is not a member of the organization"); + } + + return { + userId, + membership, + file, + organizationId, + read: true, + upload: true, + delete: file?.uploadedBy === userId || membership.permission === "admin", + }; +} + +/** + * Validate file attachments for message creation + * Ensures all files belong to the same organization as the channel + */ +export async function validateFileAttachments( + ctx: QueryCtx, + fileIds: Id<"files">[], + channelId: Id<"channels">, +) { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new Error("User is not authenticated"); + } + + const channel = await ctx.db.get(channelId); + if (!channel) { + throw new Error("Channel not found"); + } + + for (const fileId of fileIds) { + const file = await ctx.db.get(fileId); + if (!file) { + throw new Error(`File not found: ${fileId}`); + } + + if (file.organizationId !== channel.organizationId) { + throw new Error("File attachment belongs to different organization"); + } + } +}