Files
opengroupware/docs/functional-design.md
Ken Yasue 921cdc457d feat(ux): dark/light theme + en/ja i18n + user preferences
UX全体にテーマと言語設定を追加。

- lib/db/migrations/004_user_prefs.sql: users に theme/locale 列を追加(既定 dark/en)
- lib/i18n/: dictionary(en/ja) + I18nProvider(クライアントcontext: locale/theme/t/setLocale/setTheme) + server.ts(SSR用 getLocale/getTheme/translate) + constants.ts
- app/layout.tsx: theme/locale Cookie を読み <html class/lang> をSSR、I18nProvider でラップ(フラッシュなし)
- tailwind darkMode:'class' + 全画面に dark: バリアントを一括付与(Nodeスクリプト lookbehind で安全に変換)
- components/layout/ThemeToggle: 即時クラス切替+Cookie+永続化
- app/profile: テーマ/言語セレクタ、chrome翻訳(Header/ProjectNav/login/dashboard/profile)
- PATCH /api/users/me: theme/locale を受理(バリデーション→400)、Cookieを設定
- E2E: locale=ja storageState で既存JAアサーションを維持、theme-i18n.spec で既定en/darkと切替を検証
2026-06-25 11:37:44 +02:00

1106 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 機能設計書 (Functional Design Document)
> 本書は `docs/product-requirements.md` で定義された「何を作るか」を「どのように実現するか」に翻訳した機能設計書である。
## システムアーキテクチャ図
### 全体構成
```mermaid
graph TB
Browser[ブラウザ<br/>React Server Components + Client Components]
Next[Next.js 15<br/>Node.js Runtime]
RH[Route Handler / Server Action]
SSE[SSEエンドポイント]
SVC[Service Layer<br/>業務ロジック・権限チェック]
REPO[Repository Layer<br/>SQL保持・データアクセス]
WRAP[SQL Wrapper<br/>lib/db/sqlite.ts]
MIG[Migrator<br/>lib/db/migrator.ts]
DB[(SQLite<br/>better-sqlite3 / WAL)]
FS[ローカルファイルシステム<br/>uploads/]
Browser --> Next
Next --> RH
Next --> SSE
RH --> SVC
SVC --> REPO
REPO --> WRAP
WRAP --> DB
SVC --> FS
MIG --> WRAP
SSE --> SVC
SVC --> REPO
```
### DBアクセスフロー
```mermaid
graph LR
RH[Route Handler / Server Action] --> SVC[Service]
SVC --> REPO[Repository]
REPO --> WRAP[SQL Wrapper]
WRAP --> DB[(SQLite)]
```
- 各Repositoryは直接SQLiteライブラリを触らず、必ず共通SQLラッパー`lib/db/sqlite.ts`を通してSQLを実行する。
- Service層は業務ロジック・権限チェック・トランザクション境界を担う。
- すべてNode.js Runtimeで実行するEdge Runtimeは使用しない
## 技術スタック
| カテゴリ | 技術 | 選定理由 |
|------|------|----------|
| 言語 | TypeScript | 型安全性と保守性 |
| フレームワーク | Next.js 15 | App Router・Server Components・Route Handlers・Server Actionsを活用 |
| ランタイム | Node.js Runtime | SQLite(better-sqlite3)直接操作のためEdge Runtime不使用 |
| データベース | SQLite | 外部DB不要・自己完結・小規模チーム向け |
| SQLiteライブラリ | better-sqlite3 | 同期APIで扱いやすく高速 |
| DBアクセス | 独自SQLラッパー + Repositoryクラス | Prisma不使用・SQLを直接制御 |
| Migration | 独自SQL Migration | ファイル名順実行・トランザクション保証 |
| 認証 | 独自ログイン方式 | セッションベース・外部IdP非依存 |
| リアルタイム通信 | SSE (Server-Sent Events) | WebSocket不要・プロジェクト単位配信 |
| ファイル保存 | ローカルファイルシステム | 外部ストレージ不要・自己完結 |
| UI | Tailwind CSS | ユーティリティファーストで高速なUI構築 |
| Markdown表示 | react-markdown + remark-gfm + rehype-sanitize | GFM対応・HTML無効化・サニタイズ |
| ファイル閲覧 | Lightbox UI | 画像・PDFのプレビュー |
| カレンダーUI | FullCalendar系 または 独自実装 | 月/週/日/リスト表示 |
| Unit Test | Vitest | Viteベース・高速 |
| E2E Test | Playwright | 実ブラウザ操作の検証 |
## データモデル定義
### エンティティ一覧
全16テーブル管理テーブル1`schema_migrations`。タイムスタンプはISO8601文字列`TEXT`)で保存する。論理削除は `deleted_at TEXT`NULL=未削除)で表現する。真偽値は `INTEGER`0/1で保存する。
### users
```typescript
interface User {
id: number; // PK AUTOINCREMENT
name: string; // 表示名1-100文字
email: string; // 一意・ログインID
passwordHash: string | null; // bcrypt等でハッシュ化
avatarUrl: string | null; // アイコン画像URL
role: UserRole; // 'system_admin' | 'project_admin' | 'member' | 'guest'
status: UserStatus; // 'active' | 'inactive'
theme: Theme; // 'dark' | 'light' (既定 dark)
locale: Locale; // 'en' | 'ja' (既定 en)
createdAt: string; // ISO8601
updatedAt: string; // ISO8601
}
type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive';
type Theme = 'dark' | 'light';
type Locale = 'en' | 'ja';
```
**制約**: emailは一意。passwordHashは平文保存しない。status='inactive'はログイン不可。
### projects
```typescript
interface Project {
id: number;
name: string; // 1-200文字
description: string | null;
status: ProjectStatus; // 'active' | 'on_hold' | 'completed' | 'archived'
ownerId: number; // FK users.id
createdAt: string;
updatedAt: string;
}
type ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived';
```
### project_members
```typescript
interface ProjectMember {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
userId: number; // FK users.id ON DELETE CASCADE
role: ProjectMemberRole; // 'admin' | 'member' | 'guest'
joinedAt: string;
}
type ProjectMemberRole = 'admin' | 'member' | 'guest';
```
**制約**: UNIQUE(project_id, user_id)。
### board_threads
```typescript
interface BoardThread {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
title: string; // 1-200文字
bodyMd: string; // Markdown本文
authorId: number; // FK users.id
category: BoardCategory | null;
isPinned: number; // 0/1
isImportant: number; // 0/1
createdAt: string;
updatedAt: string;
deletedAt: string | null; // 論理削除
}
type BoardCategory = 'notice' | 'spec' | 'minutes' | 'question' | 'decision' | 'trouble' | 'memo';
```
### board_comments
```typescript
interface BoardComment {
id: number;
threadId: number; // FK board_threads.id ON DELETE CASCADE
authorId: number; // FK users.id
bodyMd: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
```
### chat_messages
```typescript
interface ChatMessage {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
authorId: number; // FK users.id
body: string; // プレーンテキスト想定(メンション@含む)
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
```
### todo_columns
```typescript
interface TodoColumn {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
name: string;
orderIndex: number;
createdAt: string;
updatedAt: string;
}
```
### todo_items
```typescript
interface TodoItem {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
columnId: number; // FK todo_columns.id ON DELETE CASCADE
title: string; // 1-200文字
description: string | null;
assigneeId: number | null; // FK users.id
creatorId: number; // FK users.id
priority: TodoPriority; // 'low' | 'normal' | 'high'
startDate: string | null; // ISO8601 date
dueDate: string | null; // ISO8601 date
completedAt: string | null;
orderIndex: number;
milestoneId: number | null; // FK milestones.id
tags: string | null; // カンマ区切りのタグ(project_notes.tags と同じ方式)
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type TodoPriority = 'low' | 'normal' | 'high';
```
### file_assets
```typescript
interface FileAsset {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
uploaderId: number; // FK users.id
filename: string; // 保存ファイル名(一意)
originalName: string; // 元ファイル名
mimeType: string; // MIMEタイプアップロード時チェック
size: number; // バイト数
path: string; // ローカルパスuploads/...
source: FileAssetSource; // 'library'(Files一覧公開) | 'attachment'(添付専用)
createdAt: string;
deletedAt: string | null;
}
type FileAssetSource = 'library' | 'attachment';
```
### attachments
```typescript
// file_assets とチャット/掲示板/ToDo を多対多で紐付ける
interface Attachment {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
fileId: number; // FK file_assets.id
targetType: AttachmentTargetType;
targetId: number; // targetType に応じた chat_message/board_thread/board_comment/todo_item の id
createdAt: string;
deletedAt: string | null;
}
type AttachmentTargetType =
| 'chat_message'
| 'board_thread'
| 'board_comment'
| 'todo_item';
```
### project_notes
```typescript
interface ProjectNote {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
title: string; // 1-200文字
bodyMd: string; // Markdown本文
tags: string | null; // カンマ区切り
isPinned: number; // 0/1
createdById: number; // FK users.id
updatedById: number; // FK users.id
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
```
**インデックス**: `idx_project_notes_project_id(project_id)`, `idx_project_notes_updated_at(updated_at)`
### milestones
```typescript
interface Milestone {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
title: string;
description: string | null;
dueDate: string | null; // ISO8601 date
status: MilestoneStatus; // 'open' | 'closed'
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type MilestoneStatus = 'open' | 'closed';
```
### calendar_events
```typescript
interface CalendarEvent {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
title: string;
description: string | null;
type: CalendarEventType; // 'meeting' | 'deadline' | 'milestone' | 'todo' | 'reminder' | 'custom'
startAt: string; // ISO8601 datetime
endAt: string | null;
createdById: number; // FK users.id
relatedTodoId: number | null;
relatedMilestoneId: number | null;
relatedMeetingId: number | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type CalendarEventType = 'meeting' | 'deadline' | 'milestone' | 'todo' | 'reminder' | 'custom';
```
### meetings
```typescript
interface Meeting {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
title: string;
description: string | null;
location: string | null;
meetingUrl: string | null;
startAt: string; // ISO8601 datetime
endAt: string; // ISO8601 datetime
agendaMd: string | null; // アジェンダMarkdown
minutesMd: string | null; // 議事録Markdown
createdById: number; // FK users.id
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
```
### meeting_members
```typescript
interface MeetingMember {
id: number;
meetingId: number; // FK meetings.id ON DELETE CASCADE
userId: number; // FK users.id
status: MeetingMemberStatus; // 'invited' | 'accepted' | 'declined'
}
type MeetingMemberStatus = 'invited' | 'accepted' | 'declined';
```
**制約**: UNIQUE(meeting_id, user_id)。
### notifications
```typescript
interface Notification {
id: number;
userId: number; // FK users.id ON DELETE CASCADE
projectId: number | null; // FK projects.id ON DELETE CASCADE
type: NotificationType;
title: string;
body: string | null;
readAt: string | null; // NULL=未読
createdAt: string;
}
type NotificationType =
| 'mention' | 'todo_assigned' | 'todo_due_soon'
| 'meeting_invited' | 'board_commented' | 'project_added'
| 'file_shared' | 'note_updated';
```
### activity_logs
```typescript
interface ActivityLog {
id: number;
projectId: number; // FK projects.id ON DELETE CASCADE
actorId: number; // FK users.id
action: string; // 'todo_created' | 'file_uploaded' 等
targetType: string; // 'todo' | 'file' | 'thread' 等
targetId: number | null;
metadataJson: string | null; // JSON文字列
createdAt: string;
}
```
### schema_migrations管理テーブル
```typescript
interface SchemaMigration {
id: number;
filename: string; // 一意
appliedAt: string; // ISO8601
}
```
### ER図
```mermaid
erDiagram
users ||--o{ projects : owns
users ||--o{ project_members : participates
projects ||--o{ project_members : has
projects ||--o{ board_threads : has
projects ||--o{ chat_messages : has
projects ||--o{ todo_columns : has
projects ||--o{ todo_items : has
projects ||--o{ file_assets : has
projects ||--o{ project_notes : has
projects ||--o{ milestones : has
projects ||--o{ calendar_events : has
projects ||--o{ meetings : has
projects ||--o{ notifications : has
projects ||--o{ activity_logs : has
board_threads ||--o{ board_comments : has
users ||--o{ board_threads : authors
users ||--o{ board_comments : authors
users ||--o{ chat_messages : authors
todo_columns ||--o{ todo_items : contains
users ||--o{ todo_items : assigned
milestones ||--o{ todo_items : tracks
meetings ||--o{ meeting_members : has
users ||--o{ meeting_members : attends
users ||--o{ notifications : receives
projects {
integer id PK
text name
text status
integer owner_id FK
}
users {
integer id PK
text email
text role
text status
}
todo_items {
integer id PK
integer project_id FK
integer column_id FK
integer assignee_id FK
integer milestone_id FK
text due_date
text tags
}
meetings {
integer id PK
integer project_id FK
text start_at
text end_at
}
```
## コンポーネント設計
### SQLラッパー: `lib/db/sqlite.ts`
**責務**: SQLite接続の共通管理・SELECT/INSERT/UPDATE/DELETE・トランザクション・初期設定・エラー処理。
```typescript
export type SqlParams = Record<string, unknown> | unknown[];
export class SqliteDatabase {
constructor(dbPath: string); // journal_mode=WAL, foreign_keys=ON を設定
query<T>(sql: string, params?: SqlParams): T[];
get<T>(sql: string, params?: SqlParams): T | null;
execute(sql: string, params?: SqlParams): { changes: number; lastInsertRowid: number | bigint };
transaction<T>(callback: () => T): T;
close(): void;
}
export function getDb(): SqliteDatabase; // シングルトン。dbPath = process.env.SQLITE_PATH ?? "./data/app.db"
```
**依存**: better-sqlite3。
### Migrator: `lib/db/migrator.ts`
**責務**: Migrationファイルをファイル名順に実行・実行済み管理・トランザクション・失敗時ロールバック。
```typescript
export class Migrator {
constructor(db: SqliteDatabase, migrationsDir: string);
migrate(): void; // schema_migrations作成→未適用ファイルを順に1ファイル1トランザクションで実行
}
```
### Repository層
各テーブルごとにRepositoryクラスを作成する。RepositoryはSQLを直接保持し、SQLラッパー経由で実行する。論理削除テーブルは取得時に `deleted_at IS NULL` を必ず付与する。
```typescript
// 共通インターフェース例
interface UserRepository {
findById(id: number): User | null;
findByEmail(email: string): User | null;
create(data: { name: string; email: string; passwordHash: string; role?: UserRole }): User;
update(id: number, data: Partial<Pick<User, 'name' | 'avatarUrl' | 'role' | 'status'>>): User | null;
}
interface ProjectRepository {
findById(id: number): Project | null;
findByOwner(ownerId: number): Project[];
create(data: { name: string; description?: string; ownerId: number }): Project;
update(id: number, data: Partial<Pick<Project, 'name' | 'description' | 'status'>>): Project | null;
delete(id: number): boolean;
}
interface ProjectMemberRepository {
findByProject(projectId: number): (ProjectMember & { user?: User })[];
findByUser(userId: number): (ProjectMember & { project?: Project })[];
add(projectId: number, userId: number, role: ProjectMemberRole): ProjectMember;
remove(projectId: number, userId: number): boolean;
isMember(projectId: number, userId: number): boolean;
getRole(projectId: number, userId: number): ProjectMemberRole | null;
}
interface BoardRepository { /* thread/comment CRUD + 検索 + ページネーション */ }
interface ChatRepository { /* message CRUD + ページネーション + 検索 */ }
interface TodoRepository { /* column/item CRUD + 並び替え + ページネーション */ }
interface FileRepository { /* asset CRUD + フォルダ */ }
interface CalendarRepository { /* event CRUD + 期間検索 */ }
interface MeetingRepository { /* meeting/member CRUD */ }
interface ProjectNoteRepository { /* note CRUD + 検索 + ピン留め */ }
interface NotificationRepository { /* 作成・未読一覧・既読化 */ }
interface ActivityLogRepository { /* 作成・一覧 */ }
interface MilestoneRepository { /* CRUD + 関連ToDo取得 */ }
```
**Repository一覧**: UserRepository, ProjectRepository, ProjectMemberRepository, BoardRepository, ChatRepository, TodoRepository, FileRepository, CalendarRepository, MeetingRepository, ProjectNoteRepository, NotificationRepository, ActivityLogRepository, MilestoneRepository。
### Service層
業務ロジック・権限チェック・トランザクション境界・副作用通知生成・アクティビティログ記録・SSE配信を担う。
```typescript
interface AuthService {
register(input: { name: string; email: string; password: string }): User;
login(email: string, password: string): { user: User; token: string };
logout(): void;
getCurrentUser(): User | null;
updateProfile(userId: number, input: Partial<{ name: string; avatarUrl: string; email: string }>): User;
}
interface ProjectService {
createProject(actorId: number, input: { name: string; description?: string }): Project;
updateProject(actorId: number, projectId: number, input: Partial<{ name: string; description: string; status: string }>): Project;
addMember(actorId: number, projectId: number, userId: number, role: ProjectMemberRole): void; // 権限チェック・通知
removeMember(actorId: number, projectId: number, userId: number): void;
archiveProject(actorId: number, projectId: number): void;
getDashboard(projectId: number, userId: number): ProjectDashboard;
}
interface ChatService {
sendMessage(actorId: number, projectId: number, body: string): ChatMessage; // SSE配信 + メンション通知
editMessage(actorId: number, messageId: number, body: string): ChatMessage;
deleteMessage(actorId: number, messageId: number): void;
getHistory(projectId: number, page: number): { items: ChatMessage[]; total: number };
}
interface MeetingService {
createMeeting(actorId: number, projectId: number, input: MeetingInput): { meeting: Meeting; conflicts: ScheduleConflict[] };
checkScheduleConflicts(projectId: number, memberIds: number[], startAt: string, endAt: string, excludeMeetingId?: number): ScheduleConflict[];
updateMinutes(actorId: number, meetingId: number, minutesMd: string): Meeting;
}
interface ScheduleService {
getCalendarEvents(projectId: number, range: { from: string; to: string }, filters?: CalendarFilters): CalendarEventView[];
}
interface FileStorageService {
upload(actorId: number, projectId: number, file: File): FileAsset; // MIMEチェック・保存・アクティビティログ
getDownloadStream(fileAssetId: number, userId: number): ReadableStream;
delete(actorId: number, fileAssetId: number): void;
}
interface BackupService {
createBackup(actorId: number): { filename: string; path: string }; // DB+uploadsをZIP化
listBackups(): BackupFile[];
downloadBackup(actorId: number, filename: string): ReadableStream;
}
```
**Service一覧**: AuthService, ProjectService, ChatService, MeetingService, ScheduleService, FileStorageService, BackupService。
### SSE配信基盤: `lib/sse/`
**責務**: プロジェクト単位のSSEクライアント管理・イベント配信・自動再接続対応。
```typescript
// プロジェクト別のクライアント集合を管理
class SseHub {
addClient(projectId: number, res: ServerResponse): void;
removeClient(projectId: number, res: ServerResponse): void;
broadcast(projectId: number, event: SseEvent): void; // 当該プロジェクトの全クライアントへ配信
}
type SseEvent =
| { type: 'chat.message.created'; data: ChatMessage }
| { type: 'chat.message.updated'; data: ChatMessage }
| { type: 'chat.message.deleted'; data: { id: number } }
| { type: 'todo.updated'; data: TodoItem }
| { type: 'file.uploaded'; data: FileAsset }
| { type: 'meeting.created'; data: Meeting }
| { type: 'note.updated'; data: ProjectNote }
| { type: 'notification.created'; data: Notification };
```
## ユースケース図(シーケンス)
### ユースケース1: ログイン
```mermaid
sequenceDiagram
participant U as ユーザー
participant B as ブラウザ
participant RH as Route Handler /login
participant Auth as AuthService
participant Repo as UserRepository
participant DB as SQLite
U->>B: メール/パスワード入力
B->>RH: POST /api/auth/login
RH->>Auth: login(email, password)
Auth->>Repo: findByEmail(email)
Repo->>DB: SELECT
DB-->>Repo: User
Repo-->>Auth: User
Auth->>Auth: パスワードハッシュ検証
Auth->>Auth: セッション発行
Auth-->>RH: { user, token }
RH-->>B: Set-Cookie + 200
B-->>U: ダッシュボードへ遷移
```
### ユースケース2: チャットメッセージ送信SSE配信
```mermaid
sequenceDiagram
participant U as ユーザーA
participant UA as ブラウザA
participant UB as ブラウザB(同一プロジェクト)
participant RH as Route Handler
participant Chat as ChatService
participant Repo as ChatRepository
participant Hub as SseHub
participant Notif as NotificationService
U->>UA: メッセージ入力(@userB)
UA->>RH: POST /api/projects/:id/chat
RH->>Chat: sendMessage(actorA, projectId, body)
Chat->>Repo: create(message)
Chat->>Notif: メンション検出→通知作成(userB)
Chat->>Hub: broadcast(projectId, chat.message.created)
Hub-->>UA: SSE event
Hub-->>UB: SSE event
Chat-->>RH: message
RH-->>UA: 201 created
UA-->>U: 表示更新
UB-->>U: リアルタイム表示
```
### ユースケース3: ミーティング作成(予定重複チェック)
```mermaid
sequenceDiagram
participant U as ユーザー
participant RH as Route Handler
participant M as MeetingService
participant Sched as ScheduleService
participant Repo as MeetingRepository
participant Hub as SseHub
U->>RH: POST /api/projects/:id/meetings
RH->>M: createMeeting(actor, projectId, input)
M->>Sched: checkScheduleConflicts(projectId, memberIds, start, end)
Sched-->>M: conflicts[]
M->>Repo: create(meeting) + addMembers(...)
M->>Hub: broadcast(meeting.created)
M-->>RH: { meeting, conflicts }
RH-->>U: 201 + 警告表示conflictsがあれば
```
### ユースケース4: ToDoドラッグ&ドロップ移動
```mermaid
sequenceDiagram
participant U as ユーザー
participant RH as Route Handler
participant Todo as TodoService
participant Repo as TodoRepository
participant Hub as SseHub
participant Log as ActivityLogService
U->>RH: PATCH /api/projects/:id/todos/:id { columnId, orderIndex }
RH->>Todo: moveTodo(actor, todoId, columnId, orderIndex)
Todo->>Repo: update(columnId, orderIndex)
Todo->>Log: record(todo_updated)
Todo->>Hub: broadcast(todo.updated)
Todo-->>RH: todo
RH-->>U: 200
```
## 画面遷移図
```mermaid
stateDiagram-v2
[*] --> Login: 未認証
Login --> Dashboard: ログイン成功
Dashboard --> Profile: プロフィール
Dashboard --> Notifications: 通知一覧
Dashboard --> AdminSettings: 管理者のみ
AdminSettings --> Backup: バックアップ管理
Dashboard --> ProjectOverview: プロジェクト選択
ProjectOverview --> Board
ProjectOverview --> Chat
ProjectOverview --> Todo
ProjectOverview --> Files
ProjectOverview --> Notes
ProjectOverview --> Calendar
ProjectOverview --> Milestones
ProjectOverview --> Meetings
ProjectOverview --> Members
ProjectOverview --> ActivityLog
ProjectOverview --> ProjectSettings
Dashboard --> Login: ログアウト
```
### 共通画面
- ログイン画面・ダッシュボード・通知一覧・ユーザープロフィール・管理者設定・バックアップ管理
### プロジェクト内画面
- プロジェクト概要・掲示板・チャット・ToDo/Kanban・ファイル・Markdownメモ・カレンダー・マイルストーン・ミーティング・メンバー・アクティビティログ・設定
## API設計
Route Handlerベース。全エンドポイントで認証必須`/api/auth/*`除く)。プロジェクト系エンドポイントは参加者権限チェックを行う。ページネーションは `?page=1&pageSize=20`
### 認証
| メソッド | パス | 説明 |
|------|------|------|
| POST | `/api/auth/register` | ユーザー登録 |
| POST | `/api/auth/login` | ログイン |
| POST | `/api/auth/logout` | ログアウト |
| GET | `/api/auth/me` | 現在のユーザー |
| PATCH | `/api/users/me` | プロフィール編集 |
### プロジェクト
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects` | 自分の参加プロジェクト一覧 |
| POST | `/api/projects` | プロジェクト作成 |
| GET | `/api/projects/:projectId` | プロジェクト詳細/ダッシュボード |
| PATCH | `/api/projects/:projectId` | プロジェクト編集 |
| DELETE | `/api/projects/:projectId` | プロジェクト削除 |
| GET | `/api/projects/:projectId/members` | メンバー一覧 |
| POST | `/api/projects/:projectId/members` | メンバー追加 |
| DELETE | `/api/projects/:projectId/members/:userId` | メンバー削除 |
### 掲示板
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects/:projectId/board/threads` | スレッド一覧(ページネーション・検索) |
| POST | `/api/projects/:projectId/board/threads` | スレッド作成 |
| GET | `/api/projects/:projectId/board/threads/:threadId` | スレッド詳細 |
| PATCH | `/api/projects/:projectId/board/threads/:threadId` | スレッド編集 |
| DELETE | `/api/projects/:projectId/board/threads/:threadId` | スレッド削除 |
| POST | `/api/projects/:projectId/board/threads/:threadId/comments` | コメント作成 |
| PATCH | `/api/projects/:projectId/board/comments/:commentId` | コメント編集 |
| DELETE | `/api/projects/:projectId/board/comments/:commentId` | コメント削除 |
### チャット
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects/:projectId/chat/messages` | 履歴(ページネーション・検索) |
| POST | `/api/projects/:projectId/chat/messages` | メッセージ送信 |
| PATCH | `/api/projects/:projectId/chat/messages/:messageId` | メッセージ編集 |
| DELETE | `/api/projects/:projectId/chat/messages/:messageId` | メッセージ削除 |
| GET | `/api/projects/:projectId/chat/stream` | **SSEエンドポイント**(プロジェクト別) |
### ToDo / Kanban
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects/:projectId/todos/columns` | カラム一覧 |
| POST | `/api/projects/:projectId/todos/columns` | カラム作成 |
| PATCH | `/api/projects/:projectId/todos/columns/:columnId` | カラム編集/並び替え |
| DELETE | `/api/projects/:projectId/todos/columns/:columnId` | カラム削除 |
| GET | `/api/projects/:projectId/todos/items` | タスク一覧 |
| POST | `/api/projects/:projectId/todos/items` | タスク作成 |
| PATCH | `/api/projects/:projectId/todos/items/:itemId` | タスク編集/移動 |
| DELETE | `/api/projects/:projectId/todos/items/:itemId` | タスク削除 |
### ファイル
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects/:projectId/files` | ファイル一覧 |
| POST | `/api/projects/:projectId/files` | アップロードmultipart |
| GET | `/api/files/:fileId/download` | ダウンロード(権限チェック) |
| DELETE | `/api/files/:fileId` | 削除 |
### Markdownメモ
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects/:projectId/notes` | メモ一覧(検索・ページネーション) |
| POST | `/api/projects/:projectId/notes` | メモ作成 |
| GET | `/api/projects/:projectId/notes/:noteId` | メモ詳細 |
| PATCH | `/api/projects/:projectId/notes/:noteId` | メモ編集 |
| DELETE | `/api/projects/:projectId/notes/:noteId` | メモ削除 |
### カレンダー / マイルストーン / ミーティング
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/projects/:projectId/calendar/events` | イベント一覧(期間・フィルタ) |
| POST | `/api/projects/:projectId/calendar/events` | イベント作成 |
| PATCH | `/api/projects/:projectId/calendar/events/:eventId` | 編集 |
| DELETE | `/api/projects/:projectId/calendar/events/:eventId` | 削除 |
| GET | `/api/projects/:projectId/milestones` | マイルストーン一覧 |
| POST | `/api/projects/:projectId/milestones` | 作成 |
| PATCH | `/api/projects/:projectId/milestones/:id` | 編集 |
| GET | `/api/projects/:projectId/milestones/:id/progress` | 進捗率取得 |
| GET | `/api/projects/:projectId/meetings` | ミーティング一覧 |
| POST | `/api/projects/:projectId/meetings` | 作成conflicts返却 |
| PATCH | `/api/projects/:projectId/meetings/:id` | 編集 |
| POST | `/api/projects/:projectId/meetings/check` | 予定重複チェックのみ |
### 通知 / アクティビティ / 検索 / バックアップ / Migration
| メソッド | パス | 説明 |
|------|------|------|
| GET | `/api/notifications` | 未読通知一覧 |
| POST | `/api/notifications/:id/read` | 既読化 |
| GET | `/api/projects/:projectId/activity` | アクティビティログ一覧 |
| GET | `/api/projects/:projectId/search` | 横断検索 |
| GET | `/api/admin/backups` | バックアップ一覧(管理者) |
| POST | `/api/admin/backups` | バックアップ作成(管理者) |
| GET | `/api/admin/backups/:filename` | ダウンロード(管理者) |
| GET | `/api/admin/migrations` | Migration状態確認管理者 |
### エラーレスポンス
```
400 Bad Request: バリデーションエラー・必須項目不足
401 Unauthorized: 未認証
403 Forbidden: 権限不足(プロジェクト非参加者・管理者機能への非管理者アクセス)
404 Not Found: リソース不存在・論理削除済み
409 Conflict: 一意制約違反email重複等
500 Internal Server Error: サーバー内部エラー
```
エラーレスポンス本文:
```json
{ "error": { "code": "VALIDATION_ERROR", "message": "タイトルは1-200文字で入力してください" } }
```
## アルゴリズム設計
### アルゴリズム1: スケジュール重複判定
**目的**: ミーティング作成時、選択された参加メンバーの予定重複を検出する。
**入力**: `projectId`, `memberIds: number[]`, `startAt: string`, `endAt: string`, `excludeMeetingId?: number`
**判定対象**:
1. 他のミーティングmeeting_members経由で該当ユーザーが参加、かつ時間重複
2. カレンダーイベント(該当ユーザーが作成者、かつ時間重複)
3. 期限の近い重要タスク該当ユーザーが担当、priority='high'、dueDateが会議日±3日以内
**時間重複判定**: `NOT (existing.end <= new.start OR existing.start >= new.end)`
```typescript
interface ScheduleConflict {
userId: number;
type: 'meeting' | 'calendar_event' | 'important_todo';
refId: number;
title: string;
startAt: string;
endAt: string | null;
}
function checkScheduleConflicts(
projectId: number,
memberIds: number[],
startAt: string,
endAt: string,
excludeMeetingId?: number
): ScheduleConflict[] {
const conflicts: ScheduleConflict[] = [];
const newStart = new Date(startAt).getTime();
const newEnd = new Date(endAt).getTime();
for (const userId of memberIds) {
// 1. 他ミーティング
const meetings = meetingRepo.findUpcomingByUser(userId, newStart, newEnd, excludeMeetingId);
for (const m of meetings) {
if (overlaps(newStart, newEnd, m.startAt, m.endAt)) {
conflicts.push({ userId, type: 'meeting', refId: m.id, title: m.title, startAt: m.startAt, endAt: m.endAt });
}
}
// 2. カレンダーイベント
const events = calendarRepo.findByUserInRange(userId, newStart, newEnd);
for (const e of events) {
if (e.endAt && overlaps(newStart, newEnd, e.startAt, e.endAt)) {
conflicts.push({ userId, type: 'calendar_event', refId: e.id, title: e.title, startAt: e.startAt, endAt: e.endAt });
}
}
// 3. 期限の近い重要タスク±3日
const todos = todoRepo.findHighPriorityByAssignee(userId, dayRange(startAt, 3));
for (const t of todos) {
conflicts.push({ userId, type: 'important_todo', refId: t.id, title: t.title, startAt: t.dueDate!, endAt: null });
}
}
return conflicts;
}
function overlaps(s1: number, e1: number, s2: string, e2: string): boolean {
const start2 = new Date(s2).getTime();
const end2 = new Date(e2).getTime();
return !(end2 <= s1 || start2 >= e1);
}
```
**出力**: 重複があれば警告として返却(作成自体はブロックしない)。
### アルゴリズム2: マイルストーン進捗率自動計算
**目的**: 関連ToDoの完了率から進捗率を自動計算する。
```typescript
function calcMilestoneProgress(milestoneId: number): number {
const todos = todoRepo.findByMilestone(milestoneId); // deleted_at IS NULL
if (todos.length === 0) return 0;
const completed = todos.filter((t) => t.completedAt !== null).length;
return Math.round((completed / todos.length) * 100);
}
```
**出力**: 0-100整数
### アルゴリズム3: 通知作成ロジック
**目的**: 各イベント発生時に、適切な対象ユーザーへ通知を作成する。
```typescript
function notifyOnEvent(event: NotificationEvent): void {
const targets = resolveTargets(event); // イベント種別→対象ユーザー集合
for (const userId of targets) {
notificationRepo.create({
userId,
projectId: event.projectId,
type: event.type,
title: event.title,
body: event.body,
});
sseHub.broadcast(event.projectId, { type: 'notification.created', data: ... });
}
}
function resolveTargets(event: NotificationEvent): number[] {
switch (event.type) {
case 'mention': return [event.mentionedUserId];
case 'todo_assigned': return [event.assigneeId];
case 'todo_due_soon': return [event.assigneeId];
case 'meeting_invited': return event.memberIds;
case 'board_commented': return [event.threadAuthorId];
case 'project_added': return [event.addedUserId];
case 'file_shared': return event.projectMemberIds;
case 'note_updated': return event.projectMemberIds;
}
}
```
### アルゴリズム4: アクティビティログ作成ロジック
**目的**: 変更操作をプロジェクト単位の時系列ログとして記録する。
```typescript
function logActivity(input: {
projectId: number;
actorId: number;
action: string; // 'todo_created' | 'file_uploaded' | ...
targetType: string; // 'todo' | 'file' | 'thread' | ...
targetId: number;
metadata?: Record<string, unknown>;
}): void {
activityLogRepo.create({
projectId: input.projectId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadataJson: input.metadata ? JSON.stringify(input.metadata) : null,
});
}
```
**記録対象操作**: todo_created, todo_updated, todo_completed, file_uploaded, board_posted, comment_added, note_created, note_updated, meeting_created, member_added, milestone_updated。
## UI設計
### プロジェクトダッシュボード表示項目
| 項目 | 説明 | フォーマット |
|------|------|------|
| 最新チャット | 直近5件 | メッセージ + 投稿者 + 相対時間 |
| 最新掲示板 | ピン留め優先・直近5件 | タイトル + カテゴリ + 相対時間 |
| 最新Markdownメモ | ピン留め優先・直近5件 | タイトル + 更新者 + 相対時間 |
| 未完了ToDo | 進行中タスク | タイトル + 担当者 + 期限 |
| 期限が近いToDo | 7日以内 | 期限順ソート |
| 次回ミーティング | 直近1件 | タイトル + 開始日時 + 参加者 |
| 直近マイルストーン | 進捗率付き | タイトル + 期限 + 進捗バー |
| 最近のファイル | 直近5件 | ファイル名 + サイズ + 相対時間 |
| 最近のアクティビティ | 直近10件 | アクター + アクション + 相対時間 |
### カラーコーディング
- プロジェクトステータス: Active=緑 / On Hold=黄 / Completed=青 / Archived=グレー
- ToDo優先度: high=赤 / normal=黄 / low=グレー
- 通知: 未読=強調 / 既読=通常
- マイルストーン進捗: 0-33%=赤 / 34-66%=黄 / 67-100%=緑
### Kanban操作
1. カラムは横並び・ドラッグで並び替え
2. タスクはドラッグ&ドロップでカラム間移動
3. 移動時にorderIndexを再計算
## ファイル構造
### データ保存構成
```
./data/
├── app.db # SQLite DBファイル
└── uploads/ # アップロードファイル
└── <projectId>/
└── <filename> # 一意な保存名
./lib/db/migrations/
├── 001_initial.sql # 初期スキーマ
└── ...
./backups/ # バックアップZIP
└── backup-<timestamp>.zip
```
### uploadsの保存命名
- 保存ファイル名: `<uuid>.<ext>`(衝突回避)
- `file_assets.path` に相対パスを保存
- 元ファイル名は `original_name` に保存
## パフォーマンス最適化
- **ページネーション**: 掲示板・チャット履歴・ファイル一覧・Markdownメモ・アクティビティログは必ずページネーションし必要件数のみ取得
- **インデックス**: `project_notes(project_id)`, `project_notes(updated_at)` 等の検索頻度高いカラムにインデックス付与
- **WALモード**: SQLite を WAL モードで運用し読み書きの並行性を向上
- **SSE接続**: プロジェクト単位でクライアント集合を管理し、配信は当該プロジェクトのみ(他プロジェクトへ漏れさせない)
- **Server Components**: 可能な限りServer Componentsでデータ取得しクライアント送信量を削減
## セキュリティ考慮事項
- **認証**: 全保護画面・APIで認証必須
- **認可**: プロジェクト参加者以外はプロジェクト情報・ファイルにアクセス不可Service層で`isMember`チェック)
- **SQLインジェクション対策**: 全SQLをパラメータバインドで実行文字列結合禁止
- **Markdownサニタイズ**: HTML直接入力無効化 + rehype-sanitize でサニタイズ + 危険URLスキーム除外
- **ファイルアップロード**: MIMEタイプチェック・ファイル名サニタイズ・保存名の一意化
- **パスワード**: ハッシュ化保存(平文禁止)
- **管理者機能**: System Admin ロールチェック・操作をアクティビティログに記録
## エラーハンドリング
| エラーカテゴリ | 処理 | ユーザー表示 |
|-----------|------|-----------------|
| 入力バリデーションエラー | 処理中断・400返却 | 「タイトルは1-200文字で入力してください」 |
| 未認証 | リダイレクト/401 | ログイン画面へ遷移 |
| 権限不足 | 処理中断・403 | 「この操作を行う権限がありません」 |
| リソース不存在 | 処理中断・404 | 「リソースが見つかりません」 |
| 一意制約違反 | 処理中断・409 | 「このメールアドレスは既に使用されています」 |
| Migration失敗 | 1ファイルトランザクションでロールバック | 管理者画面にエラー表示 |
| ファイル読み書き失敗 | 処理中断・500 | 「ファイルの保存に失敗しました」 |
| SSE接続切断 | クライアントで自動再接続 | (ユーザー操作不要) |
## テスト戦略
### Unit TestVitest
- SQLラッパーquery/get/execute/transaction
- Migrationファイル名順実行・再実行回避・失敗時ロールバック
- 全RepositoryCRUD・論理削除の非取得・プロジェクト分離
- 全Service権限チェック・バリデーション
- スケジュール重複判定アルゴリズム
- 通知作成ロジック(正しいユーザーへ作成)
- アクティビティログ作成ロジック
- マイルストーン進捗率計算
### 統合テスト
- 認証フロー(登録→ログイン→保護画面アクセス)
- プロジェクト作成→メンバー追加→権限分離
- チャット送信→SSE配信
### E2E TestPlaywright
- 認証・プロジェクト管理・掲示板・チャットSSEリアルタイム・ToDoドラッグ&ドロップ・ファイル共有Lightbox・Markdownメモ・カレンダー・ミーティング予定重複警告・通知・アクティビティログ・バックアップ
- `npm run test:e2e` で実行