diff --git a/CLAUDE.md b/CLAUDE.md index dd08709..7ae7708 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,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/` @@ -100,9 +31,33 @@ 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) +### 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 +import { api, type Id } from "@packages/convex"; + +// use api and type Id ... +``` + ### 注意点: convex-svelte の `useQuery` について `useQuery` に渡す引数は、関数の形式で渡してください。そうでないと、期待しない動作を引き起こす可能性があります。 @@ -141,60 +96,61 @@ 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 +## Framework - Svelte -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 +### Syntax -## Code Quality +Never use logacy svelte syntax. This project uses Svelte 5 runes mode. -### Linting and Formatting +- ❌ 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. -- **Biome**: Primary linter with strict rules -- **Prettier**: Code formatting (Biome formatter disabled) -- **Lefthook**: Pre-commit hooks for code generation and formatting +### Svelte Capabilities -### Special Biome Rules +- clsx: Svelte has clsx builtin to its class. `
{text}
` -- Svelte files have relaxed rules for unused variables/imports -- Convex files exempt from import extension requirements -- Strict style rules including parameter assignment, const assertions +- reactive class: Svelte allows defining reactive controller classes inside ".svelte.ts" files for reusability and separation of concerns. -### Pre-commit Hooks - -- Automatic code generation (`bun sync`) -- Automatic formatting (`bun fix:format`) +```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 Desktop App +## Code Quality / Coding Rules -Tauri integration requires separate development workflow: +### Common Rules -```bash -# Start backend first -bun dev:convex +- 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 -# Then start Tauri (in separate terminal) -bun dev:tauri -``` +### Svelte -Tauri conflicts with the web development server and requires more resources for compilation. +- 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";` +- 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. +- 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]` -## Coding Instructions +### Convex Rules -- **🚫 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. +- 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 new file mode 100644 index 0000000..17ef079 --- /dev/null +++ b/docs/file-upload/implement.md @@ -0,0 +1,198 @@ +# ファイルアップロード機能 設計書 + +## 概要 + +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を生成します。 + +#### `saveFileInfo` + +アップロード後のファイル情報をデータベースに保存します。 + +#### `deleteFile` + +ファイルを削除します。 + +### Queries + +#### `getFile` + +ファイル情報とアクセスURLを取得します。 + +#### `listFiles` + +Organization内のファイル一覧を取得します。 + +## フロントエンド設計 + +### コンポーネント構成 + +#### 1. ファイルアップロードコンポーネント + +**Features:** + +- ドラッグ&ドロップエリア +- ファイル選択ボタン +- 複数ファイル選択 +- アップロード進捗表示 +- バリデーション(サイズ・形式) + +#### 2. ファイルのプレビュー表示コンポーネント + +**Features:** + +- 画像プレビュー +- ファイル情報表示(名前・サイズ・形式) +- 削除ボタン + +#### 3. メッセージ内の添付ファイル表示コンポーネント + +**Features:** + +- ファイル情報表示 +- ダウンロードリンク +- 画像のインラインプレビュー + +#### 4. 既存のメッセージ入力コンポーネントを拡張 + +**追加Features:** + +- ファイル添付ボタン +- 添付ファイル一覧表示 +- 添付ファイル付きメッセージ送信 + +### アップロードフロー + +1. **ファイル選択/ドロップ** + - ファイルバリデーション + - プレビュー表示 + +2. **アップロード開始** + - `generateUploadUrl` を呼び出し + - Convex Storage へファイルアップロード + - 進捗表示 + +3. **メタデータ保存** + - `saveFileInfo` を呼び出し + - ファイル情報をデータベースに保存 + +4. **メッセージ送信** (任意) + - 添付ファイルIDを含むメッセージを送信 + +### エラーハンドリング + +- **ファイルサイズエラー**: "ファイルサイズが大きすぎます(最大10MB)" +- **形式エラー**: "サポートされていないファイル形式です" +- **ネットワークエラー**: "アップロードに失敗しました。再試行してください" +- **権限エラー**: "ファイルのアップロード権限がありません" + +## セキュリティ + +### 認証・認可 + +- **アップロード**: ログインユーザーのみ +- **アクセス**: Organization メンバーのみ +- **削除**: アップロード者またはOrganization admin + +### ファイル検証 + +- **MIMEタイプ**: クライアント・サーバー両方で検証 +- **ファイルサイズ**: 10MB制限 +- **ファイル名**: サニタイズ処理 + +### アクセス制御 + +- **プライベートURL**: 署名付きURL使用 +- **権限チェック**: ファイルアクセス時に毎回確認 + +## パフォーマンス最適化 + +### 表示最適化 + +- **遅延読み込み**: 画像の lazy loading +- **サムネイル**: 小さいプレビュー画像生成(将来実装) +- **キャッシュ**: ファイルURLのキャッシュ + +## 将来の拡張予定 + +- **ファイル管理画面**: Organization内のファイル管理機能 +- **高度なプレビュー**: PDF, 動画のプレビュー +- **ファイル検索**: ファイル名・メタデータ検索 +- **自動削除**: 古いファイルの自動削除機能 +- **帯域幅最適化**: 画像圧縮・リサイズ機能 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/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/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 29546a0..eaf8b2f 100644 --- a/packages/client/src/components/chat/MessageInput.svelte +++ b/packages/client/src/components/chat/MessageInput.svelte @@ -2,20 +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 "~/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; } - 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, {}); let messageContent = $state(""); let authorName = $state(""); + let showFileSelector = $state(false); + let attachedFiles = $state([]); $effect(() => { if (identity?.data && !authorName) { @@ -23,18 +29,29 @@ } }); + const uploader = new FileUploader(() => ({ + organizationId, + })); + async function sendMessage() { - if (!messageContent.trim()) return; + if (!messageContent.trim() && attachedFiles.length === 0) return; + + const attachments = (await uploader.uploadAll(attachedFiles)).map( + (it) => it.id, + ); await sendMessageMutation.run({ channelId, - content: messageContent.trim(), + content: messageContent.trim() || "", author: authorName.trim() || "匿名", parentId: replyingTo?._id ?? undefined, + attachments, }); messageContent = ""; + attachedFiles = []; replyingTo = null; + showFileSelector = false; } function handleKeyPress(event: KeyboardEvent) { @@ -43,18 +60,50 @@ sendMessage(); } } + + function toggleFileUploader() { + showFileSelector = !showFileSelector; + } -
+
{#if replyingTo} -
+
返信先: {replyingTo.author} {replyingTo.content}
{/if} -
+ + {#if attachedFiles.length > 0} +
+

添付ファイル:

+
+ {#each attachedFiles as file, index (file.name)} + attachedFiles.splice(index, 1)} + /> + {/each} +
+
+ {/if} + + + {#if showFileSelector} + { + showFileSelector = false; + }} + /> + {/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..264d426 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 "../../features/files/view/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/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/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"); + }; +} 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 }) => diff --git a/packages/convex/src/convex/files.ts b/packages/convex/src/convex/files.ts new file mode 100644 index 0000000..7a2d209 --- /dev/null +++ b/packages/convex/src/convex/files.ts @@ -0,0 +1,176 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { getFilePerms } from "./perms"; + +// ファイルの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 }) => { + await getFilePerms(ctx, { organizationId }); + 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 getFilePerms(ctx, { + organizationId: args.organizationId, + }); + + // ファイルサイズ制限チェック (10MB) + if (args.size > 10 * 1024 * 1024) { + throw new Error("ファイルサイズが大きすぎます(最大10MB)"); + } + + // MIMEタイプ検証 + if (!isValidMimeType(args.mimeType)) { + throw new Error("サポートされていないファイル形式です"); + } + + 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 perms = await getFilePerms(ctx, { fileId }); + + if (!perms.delete || !perms.file) { + throw new Error("ファイルを削除する権限がありません"); + } + + const file = perms.file; + + // ストレージからファイルを削除 + 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 perms = await getFilePerms(ctx, { fileId }); + const file = perms.file; + if (!file) return null; + + 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 }) => { + await getFilePerms(ctx, { organizationId }); + + 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 results = []; + + for (const fileId of fileIds) { + try { + 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 {} + } + + return results; + }, +}); diff --git a/packages/convex/src/convex/messages.ts b/packages/convex/src/convex/messages.ts index 0dbd242..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") }, @@ -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,19 @@ export const send = mutation({ if (!perms.create) { throw new Error("Insufficient permissions"); } + + // 添付ファイルの検証 + if (args.attachments && args.attachments.length > 0) { + await validateFileAttachments(ctx, args.attachments, args.channelId); + } + 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/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"); + } + } +} 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, });