diff --git a/lib/db/migrations/001_initial.sql b/lib/db/migrations/001_initial.sql new file mode 100644 index 0000000..a680f91 --- /dev/null +++ b/lib/db/migrations/001_initial.sql @@ -0,0 +1,257 @@ +-- 001_initial.sql +-- シンプルグループウェア 初期スキーマ +-- 全16テーブル + インデックス + +-- 1. users: システム利用ユーザー +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT, + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- 2. projects: プロジェクト +CREATE TABLE projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'active', + owner_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (owner_id) REFERENCES users(id) +); + +-- 3. project_members: プロジェクトメンバー +CREATE TABLE project_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL, + UNIQUE(project_id, user_id), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 4. board_threads: 掲示板スレッド +CREATE TABLE board_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + title TEXT NOT NULL, + body_md TEXT NOT NULL, + author_id INTEGER NOT NULL, + category TEXT, + is_pinned INTEGER NOT NULL DEFAULT 0, + is_important INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(id) +); + +-- 5. board_comments: 掲示板コメント +CREATE TABLE board_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (thread_id) REFERENCES board_threads(id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(id) +); + +-- 6. chat_messages: チャットメッセージ +CREATE TABLE chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(id) +); + +-- 7. todo_columns: ToDoカラム +CREATE TABLE todo_columns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- 8. todo_items: ToDoタスク +CREATE TABLE todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + column_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + assignee_id INTEGER, + creator_id INTEGER NOT NULL, + priority TEXT NOT NULL DEFAULT 'normal', + start_date TEXT, + due_date TEXT, + completed_at TEXT, + order_index INTEGER NOT NULL DEFAULT 0, + milestone_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (column_id) REFERENCES todo_columns(id) ON DELETE CASCADE, + FOREIGN KEY (assignee_id) REFERENCES users(id), + FOREIGN KEY (creator_id) REFERENCES users(id) +); + +-- 9. file_assets: ファイルアセット +CREATE TABLE file_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + uploader_id INTEGER NOT NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (uploader_id) REFERENCES users(id) +); + +-- 10. project_notes: Markdownメモ +CREATE TABLE project_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + title TEXT NOT NULL, + body_md TEXT NOT NULL, + tags TEXT, + is_pinned INTEGER NOT NULL DEFAULT 0, + created_by_id INTEGER NOT NULL, + updated_by_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES users(id), + FOREIGN KEY (updated_by_id) REFERENCES users(id) +); + +CREATE INDEX idx_project_notes_project_id ON project_notes(project_id); +CREATE INDEX idx_project_notes_updated_at ON project_notes(updated_at); + +-- 11. milestones: マイルストーン +CREATE TABLE milestones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + due_date TEXT, + status TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- 12. calendar_events: カレンダーイベント +CREATE TABLE calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL, + start_at TEXT NOT NULL, + end_at TEXT, + created_by_id INTEGER NOT NULL, + related_todo_id INTEGER, + related_milestone_id INTEGER, + related_meeting_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES users(id) +); + +-- 13. meetings: ミーティング +CREATE TABLE meetings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + location TEXT, + meeting_url TEXT, + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + agenda_md TEXT, + minutes_md TEXT, + created_by_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_id) REFERENCES users(id) +); + +-- 14. meeting_members: ミーティング参加メンバー +CREATE TABLE meeting_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + meeting_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'invited', + UNIQUE(meeting_id, user_id), + FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- 15. notifications: 通知 +CREATE TABLE notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + project_id INTEGER, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT, + read_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- 16. activity_logs: アクティビティログ +CREATE TABLE activity_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + actor_id INTEGER NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id INTEGER, + metadata_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES users(id) +); + +CREATE INDEX idx_activity_logs_project_id ON activity_logs(project_id); +CREATE INDEX idx_activity_logs_created_at ON activity_logs(created_at); +CREATE INDEX idx_notifications_user_id ON notifications(user_id); +CREATE INDEX idx_chat_messages_project_id ON chat_messages(project_id); +CREATE INDEX idx_board_threads_project_id ON board_threads(project_id); +CREATE INDEX idx_todo_items_project_id ON todo_items(project_id); +CREATE INDEX idx_meetings_project_id ON meetings(project_id); +CREATE INDEX idx_calendar_events_project_id ON calendar_events(project_id); diff --git a/lib/db/migrator.ts b/lib/db/migrator.ts new file mode 100644 index 0000000..431d9f6 --- /dev/null +++ b/lib/db/migrator.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { SqliteDatabase } from './sqlite'; + +/** + * SQLファイルベースのMigration機構。 + * - Migrationファイルをファイル名順に実行する + * - 実行済みファイルは再実行しない + * - 1ファイルごとにトランザクションを張り、失敗時はロールバックする + */ +export class Migrator { + constructor( + private readonly db: SqliteDatabase, + private readonly migrationsDir: string + ) {} + + /** + * 未適用のMigrationをファイル名順に実行する + */ + migrate(): void { + this.ensureMigrationsTable(); + + const applied = this.db.query<{ filename: string }>( + 'SELECT filename FROM schema_migrations' + ); + const appliedSet = new Set(applied.map((x) => x.filename)); + + const files = fs + .readdirSync(this.migrationsDir) + .filter((file) => file.endsWith('.sql')) + .sort(); + + for (const file of files) { + if (appliedSet.has(file)) continue; + + const fullPath = path.join(this.migrationsDir, file); + const sql = fs.readFileSync(fullPath, 'utf-8'); + + this.db.transaction(() => { + this.db.execute(sql); + this.db.execute( + `INSERT INTO schema_migrations (filename, applied_at) VALUES (@filename, @appliedAt)`, + { filename: file, appliedAt: new Date().toISOString() } + ); + }); + } + } + + /** + * 適用済みMigration一覧を取得する + */ + getAppliedMigrations(): { filename: string; applied_at: string }[] { + this.ensureMigrationsTable(); + return this.db.query<{ filename: string; applied_at: string }>( + 'SELECT filename, applied_at FROM schema_migrations ORDER BY filename' + ); + } + + private ensureMigrationsTable(): void { + this.db.execute(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ) + `); + } +} diff --git a/lib/db/run-migrations.ts b/lib/db/run-migrations.ts new file mode 100644 index 0000000..0475038 --- /dev/null +++ b/lib/db/run-migrations.ts @@ -0,0 +1,26 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { getDb, resetDbInstance } from './sqlite'; +import { Migrator } from './migrator'; + +const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations'); +const dbPath = process.env.SQLITE_PATH ?? './data/app.db'; + +const dbDir = path.dirname(dbPath); +if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); +} + +const db = getDb(); +const migrator = new Migrator(db, migrationsDir); + +migrator.migrate(); + +const applied = migrator.getAppliedMigrations(); +console.log(`Migrations applied: ${applied.length}`); +for (const m of applied) { + console.log(` - ${m.filename} (applied at ${m.applied_at})`); +} + +db.close(); +resetDbInstance(); diff --git a/lib/db/sqlite.ts b/lib/db/sqlite.ts new file mode 100644 index 0000000..6f04bfa --- /dev/null +++ b/lib/db/sqlite.ts @@ -0,0 +1,90 @@ +import Database from 'better-sqlite3'; + +export type SqlParams = Record | unknown[]; + +export interface ExecuteResult { + changes: number; + lastInsertRowid: number | bigint; +} + +/** + * SQLiteへのアクセスを一元管理する共通SQLラッパー。 + * 各Repositoryは直接better-sqlite3を触らず、必ずこのクラスを通してSQLを実行する。 + */ +export class SqliteDatabase { + private db: Database.Database; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + } + + /** + * 複数行を取得するSELECT + */ + query(sql: string, params?: SqlParams): T[] { + return this.db.prepare(sql).all(params ?? {}) as T[]; + } + + /** + * 単一行を取得するSELECT(該当なしの場合はnull) + */ + get(sql: string, params?: SqlParams): T | null { + const row = this.db.prepare(sql).get(params ?? {}) as T | undefined; + return row ?? null; + } + + /** + * INSERT / UPDATE / DELETEを実行する + */ + execute(sql: string, params?: SqlParams): ExecuteResult { + const result = this.db.prepare(sql).run(params ?? {}); + + return { + changes: result.changes, + lastInsertRowid: result.lastInsertRowid, + }; + } + + /** + * トランザクション内でコールバックを実行する + */ + transaction(callback: () => T): T { + const tx = this.db.transaction(callback); + return tx(); + } + + /** + * DB接続を閉じる + */ + close(): void { + this.db.close(); + } +} + +let instance: SqliteDatabase | null = null; + +/** + * シングルトンのDB接続を取得する。 + * dbPathは process.env.SQLITE_PATH ?? "./data/app.db" + */ +export function getDb(): SqliteDatabase { + if (!instance) { + const dbPath = process.env.SQLITE_PATH ?? './data/app.db'; + instance = new SqliteDatabase(dbPath); + } + + return instance; +} + +/** + * テスト用途: シングルトンインスタンスをリセットする + */ +export function resetDbInstance(): void { + if (instance) { + instance.close(); + instance = null; + } +} diff --git a/lib/types/index.ts b/lib/types/index.ts new file mode 100644 index 0000000..bff64f4 --- /dev/null +++ b/lib/types/index.ts @@ -0,0 +1,241 @@ +/** + * 全Entity型定義・共有型 + * functional-design.md のデータモデルに対応。 + * タイムスタンプはISO8601文字列(TEXT)。真偽値はINTEGER(0/1)。 + */ + +// ===== 列挙型 ===== + +export type UserRole = 'system_admin' | 'project_admin' | 'member' | 'guest'; +export type UserStatus = 'active' | 'inactive'; +export type ProjectStatus = 'active' | 'on_hold' | 'completed' | 'archived'; +export type ProjectMemberRole = 'admin' | 'member' | 'guest'; +export type BoardCategory = + | 'notice' + | 'spec' + | 'minutes' + | 'question' + | 'decision' + | 'trouble' + | 'memo'; +export type TodoPriority = 'low' | 'normal' | 'high'; +export type MilestoneStatus = 'open' | 'closed'; +export type CalendarEventType = + | 'meeting' + | 'deadline' + | 'milestone' + | 'todo' + | 'reminder' + | 'custom'; +export type MeetingMemberStatus = 'invited' | 'accepted' | 'declined'; +export type NotificationType = + | 'mention' + | 'todo_assigned' + | 'todo_due_soon' + | 'meeting_invited' + | 'board_commented' + | 'project_added' + | 'file_shared' + | 'note_updated'; + +// ===== エンティティ ===== + +export interface User { + id: number; + name: string; + email: string; + passwordHash: string | null; + avatarUrl: string | null; + role: UserRole; + status: UserStatus; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: number; + name: string; + description: string | null; + status: ProjectStatus; + ownerId: number; + createdAt: string; + updatedAt: string; +} + +export interface ProjectMember { + id: number; + projectId: number; + userId: number; + role: ProjectMemberRole; + joinedAt: string; +} + +export interface BoardThread { + id: number; + projectId: number; + title: string; + bodyMd: string; + authorId: number; + category: BoardCategory | null; + isPinned: number; + isImportant: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface BoardComment { + id: number; + threadId: number; + authorId: number; + bodyMd: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface ChatMessage { + id: number; + projectId: number; + authorId: number; + body: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface TodoColumn { + id: number; + projectId: number; + name: string; + orderIndex: number; + createdAt: string; + updatedAt: string; +} + +export interface TodoItem { + id: number; + projectId: number; + columnId: number; + title: string; + description: string | null; + assigneeId: number | null; + creatorId: number; + priority: TodoPriority; + startDate: string | null; + dueDate: string | null; + completedAt: string | null; + orderIndex: number; + milestoneId: number | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface FileAsset { + id: number; + projectId: number; + uploaderId: number; + filename: string; + originalName: string; + mimeType: string; + size: number; + path: string; + createdAt: string; + deletedAt: string | null; +} + +export interface ProjectNote { + id: number; + projectId: number; + title: string; + bodyMd: string; + tags: string | null; + isPinned: number; + createdById: number; + updatedById: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface Milestone { + id: number; + projectId: number; + title: string; + description: string | null; + dueDate: string | null; + status: MilestoneStatus; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface CalendarEvent { + id: number; + projectId: number; + title: string; + description: string | null; + type: CalendarEventType; + startAt: string; + endAt: string | null; + createdById: number; + relatedTodoId: number | null; + relatedMilestoneId: number | null; + relatedMeetingId: number | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface Meeting { + id: number; + projectId: number; + title: string; + description: string | null; + location: string | null; + meetingUrl: string | null; + startAt: string; + endAt: string; + agendaMd: string | null; + minutesMd: string | null; + createdById: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface MeetingMember { + id: number; + meetingId: number; + userId: number; + status: MeetingMemberStatus; +} + +export interface Notification { + id: number; + userId: number; + projectId: number | null; + type: NotificationType; + title: string; + body: string | null; + readAt: string | null; + createdAt: string; +} + +export interface ActivityLog { + id: number; + projectId: number; + actorId: number; + action: string; + targetType: string; + targetId: number | null; + metadataJson: string | null; + createdAt: string; +} + +export interface SchemaMigration { + id: number; + filename: string; + appliedAt: string; +} diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..aa9eee9 --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,46 @@ +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; +import { SqliteDatabase } from '@/lib/db/sqlite'; + +let counter = 0; + +/** + * テスト用の一時SQLite DBを作成する。 + * 実DB(一時ファイル)を使用し、テスト終了時に自動削除される。 + */ +export function createTestDb(): SqliteDatabase { + const tmpDir = os.tmpdir(); + const dbPath = path.join( + tmpDir, + `test-${process.pid}-${Date.now()}-${counter++}.db` + ); + const db = new SqliteDatabase(dbPath); + + const originalClose = db.close.bind(db); + db.close = () => { + originalClose(); + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + } + const walPath = `${dbPath}-wal`; + const shmPath = `${dbPath}-shm`; + if (fs.existsSync(walPath)) fs.unlinkSync(walPath); + if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath); + }; + + return db; +} + +/** + * マイグレーション済みのテスト用DBを作成する + */ +export function createMigratedTestDb(): SqliteDatabase { + const db = createTestDb(); + const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Migrator } = require('@/lib/db/migrator'); + const migrator = new Migrator(db, migrationsDir); + migrator.migrate(); + return db; +}