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

41 KiB
Raw Blame History

機能設計書 (Functional Design Document)

本書は docs/product-requirements.md で定義された「何を作るか」を「どのように実現するか」に翻訳した機能設計書である。

システムアーキテクチャ図

全体構成

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アクセスフロー

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テーブル管理テーブル1schema_migrations。タイムスタンプはISO8601文字列TEXT)で保存する。論理削除は deleted_at TEXTNULL=未削除)で表現する。真偽値は INTEGER0/1で保存する。

users

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

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

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

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

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

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

interface TodoColumn {
  id: number;
  projectId: number;            // FK projects.id ON DELETE CASCADE
  name: string;
  orderIndex: number;
  createdAt: string;
  updatedAt: string;
}

todo_items

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

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

// 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

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

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

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

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

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

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

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管理テーブル

interface SchemaMigration {
  id: number;
  filename: string;             // 一意
  appliedAt: string;            // ISO8601
}

ER図

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・トランザクション・初期設定・エラー処理。

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ファイルをファイル名順に実行・実行済み管理・トランザクション・失敗時ロールバック。

export class Migrator {
  constructor(db: SqliteDatabase, migrationsDir: string);
  migrate(): void;   // schema_migrations作成→未適用ファイルを順に1ファイル1トランザクションで実行
}

Repository層

各テーブルごとにRepositoryクラスを作成する。RepositoryはSQLを直接保持し、SQLラッパー経由で実行する。論理削除テーブルは取得時に deleted_at IS NULL を必ず付与する。

// 共通インターフェース例
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配信を担う。

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クライアント管理・イベント配信・自動再接続対応。

// プロジェクト別のクライアント集合を管理
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: ログイン

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配信

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: ミーティング作成(予定重複チェック)

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ドラッグ&ドロップ移動

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

画面遷移図

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: サーバー内部エラー

エラーレスポンス本文:

{ "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)

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の完了率から進捗率を自動計算する。

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: 通知作成ロジック

目的: 各イベント発生時に、適切な対象ユーザーへ通知を作成する。

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: アクティビティログ作成ロジック

目的: 変更操作をプロジェクト単位の時系列ログとして記録する。

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 で実行