From a9aef7e484ab175962f1c00a0f675ee966e80f1c Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Wed, 24 Jun 2026 23:53:30 +0200 Subject: [PATCH 1/3] save current changes --- lib/db/migrations/001_initial.sql | 257 ++++++++++++++++++++++++++++++ lib/db/migrator.ts | 68 ++++++++ lib/db/run-migrations.ts | 26 +++ lib/db/sqlite.ts | 90 +++++++++++ lib/types/index.ts | 241 ++++++++++++++++++++++++++++ tests/helpers/db.ts | 46 ++++++ 6 files changed, 728 insertions(+) create mode 100644 lib/db/migrations/001_initial.sql create mode 100644 lib/db/migrator.ts create mode 100644 lib/db/run-migrations.ts create mode 100644 lib/db/sqlite.ts create mode 100644 lib/types/index.ts create mode 100644 tests/helpers/db.ts 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; +} From 07c7d424e5935d0446d04de68d3beb4071d1abd2 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Wed, 24 Jun 2026 23:54:04 +0200 Subject: [PATCH 2/3] add steering files for M1-M4 foundation and M1-M4 milestones --- .gitignore | 3 - .../design.md | 0 .../requirements.md | 0 .../tasklist.md | 0 .steering/20260624-m1-m4-foundation/design.md | 189 ++++++++++++++++++ .../20260624-m1-m4-foundation/requirements.md | 102 ++++++++++ .../20260624-m1-m4-foundation/tasklist.md | 150 ++++++++++++++ 7 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 .steering/20260624-execute-m1-m4-milestones/design.md create mode 100644 .steering/20260624-execute-m1-m4-milestones/requirements.md create mode 100644 .steering/20260624-execute-m1-m4-milestones/tasklist.md create mode 100644 .steering/20260624-m1-m4-foundation/design.md create mode 100644 .steering/20260624-m1-m4-foundation/requirements.md create mode 100644 .steering/20260624-m1-m4-foundation/tasklist.md diff --git a/.gitignore b/.gitignore index 9bf0e74..1a184c7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,9 +47,6 @@ Thumbs.db coverage/ .nyc_output/ -# Steering files (task management - temporary) -.steering/* -!.steering/.gitkeep # Lock files (keep package-lock.json for consistency) # yarn.lock diff --git a/.steering/20260624-execute-m1-m4-milestones/design.md b/.steering/20260624-execute-m1-m4-milestones/design.md new file mode 100644 index 0000000..e69de29 diff --git a/.steering/20260624-execute-m1-m4-milestones/requirements.md b/.steering/20260624-execute-m1-m4-milestones/requirements.md new file mode 100644 index 0000000..e69de29 diff --git a/.steering/20260624-execute-m1-m4-milestones/tasklist.md b/.steering/20260624-execute-m1-m4-milestones/tasklist.md new file mode 100644 index 0000000..e69de29 diff --git a/.steering/20260624-m1-m4-foundation/design.md b/.steering/20260624-m1-m4-foundation/design.md new file mode 100644 index 0000000..0588dab --- /dev/null +++ b/.steering/20260624-m1-m4-foundation/design.md @@ -0,0 +1,189 @@ +# Design Document + +## Architecture Overview + +Layered architecture (UI → Service → Repository → Data) per `docs/architecture.md`. Next.js 15 App Router with Node.js Runtime only (no Edge Runtime). SQLite via better-sqlite3 accessed exclusively through a custom SQL wrapper. No Prisma. + +``` +app/ (UI: Route Handlers / Server Components / SSE) + ↓ +services/ (business logic, permission checks, transactions) + ↓ +repositories/ (SQL, parameter binding, logical-delete filter) + ↓ +lib/db/ (SQLite connection, SQL wrapper, Migration) +``` + +## Component Design + +### 1. SQL Wrapper: `lib/db/sqlite.ts` + +**Responsibilities**: +- SQLite connection management (singleton getDb(), WAL, foreign_keys ON) +- query (multi-row), get (single-row), execute (insert/update/delete), transaction, close +- Error handling + +**Implementation highlights**: +- dbPath = process.env.SQLITE_PATH ?? "./data/app.db" +- SqlParams = Record | unknown[] +- All repositories use this wrapper; direct better-sqlite3 access forbidden + +### 2. Migrator: `lib/db/migrator.ts` + +**Responsibilities**: +- Create schema_migrations table +- Read .sql files from migrations dir in filename order +- Skip already-applied files (tracked in schema_migrations) +- Execute each file in a transaction; rollback on failure + +### 3. Initial Schema: `lib/db/migrations/001_initial.sql` + +16 tables: users, projects, project_members, board_threads, board_comments, chat_messages, todo_columns, todo_items, file_assets, project_notes, milestones, calendar_events, meetings, meeting_members, notifications, activity_logs + indexes. + +### 4. Entity Types: `lib/types/` + +PascalCase type files for each entity + union types (UserRole, UserStatus, ProjectStatus, ProjectMemberRole, etc.). + +### 5. UserRepository + +findById, findByEmail, create (with passwordHash), update (name/avatarUrl/role/status). + +### 6. AuthService + +register (hash password, create user), login (verify hash, issue session), logout, getCurrentUser, updateProfile. Uses bcrypt. + +### 7. Session: `lib/auth/session.ts`, `lib/auth/getCurrentUser.ts` + +Cookie-based session. getCurrentUser resolves the user from the request cookie. + +### 8. ProjectRepository / ProjectMemberRepository + +Project: findById, findByOwner, findByUser (via join), create, update, delete. +ProjectMember: findByProject, findByUser, add, remove, isMember, getRole. + +### 9. ProjectService + +createProject (owner becomes admin member), updateProject, addMember (permission check + notification hook later), removeMember, archiveProject, getDashboard skeleton. + +## Data Flow + +### Login +1. POST /api/auth/login → AuthService.login(email, password) +2. AuthService → UserRepository.findByEmail → verify bcrypt hash +3. Issue session cookie → return user + +### Project creation +1. POST /api/projects → ProjectService.createProject(actorId, input) +2. ProjectService → permission (any authenticated user) → ProjectRepository.create +3. ProjectMemberRepository.add(projectId, ownerId, 'admin') +4. Return project + +## Error Handling Strategy + +### Custom Error Classes +- ValidationError (400) - field, value +- ForbiddenError (403) +- NotFoundError (404) - resource, id +- (409 for unique constraint - handled via SQLite error code) + +### Error Handling Patterns +Route Handlers catch errors and map to HTTP status. Expected errors use custom classes; unexpected errors propagate + log. + +## Test Strategy + +### Unit Tests (Vitest) +- lib/db/sqlite.test.ts (query/get/execute/transaction, WAL, foreign keys) +- lib/db/migrator.test.ts (order, skip applied, rollback) +- repositories/UserRepository.test.ts +- services/AuthService.test.ts +- repositories/ProjectRepository.test.ts +- repositories/ProjectMemberRepository.test.ts +- services/ProjectService.test.ts + +Tests use a temp SQLite file (in-memory or tmpdir) to avoid touching real data/. + +### Integration Tests (Vitest) +- project-member-permission.test.ts (non-member cannot access project data) + +### E2E Tests (Playwright) +- auth.spec.ts (login, logout, protected screen) +- project-management.spec.ts (create, edit, member add/remove, archive) + +## Dependent Libraries + +```json +{ + "dependencies": { + "next": "15", + "react": "19", + "react-dom": "19", + "better-sqlite3": "^11", + "bcrypt": "^5", + "react-markdown": "^9", + "remark-gfm": "^4", + "rehype-sanitize": "^6" + }, + "devDependencies": { + "tailwindcss": "^3", + "@types/better-sqlite3": "^7", + "@types/bcrypt": "^5", + "@playwright/test": "^1", + "tsx": "^4" + } +} +``` + +## Directory Structure + +``` +app/ + api/auth/{register,login,logout,me}/route.ts + api/users/me/route.ts + api/projects/route.ts + api/projects/[projectId]/route.ts + api/projects/[projectId]/members/route.ts + api/projects/[projectId]/members/[userId]/route.ts + api/admin/migrations/route.ts + login/page.tsx + profile/page.tsx + dashboard/page.tsx + projects/[projectId]/{page,members,settings}.tsx + layout.tsx, globals.css +lib/ + db/{sqlite.ts, migrator.ts, run-migrations.ts, migrations/001_initial.sql} + auth/{session.ts, getCurrentUser.ts} + types/*.ts + validators/{userValidator.ts, projectValidator.ts} +repositories/{UserRepository,ProjectRepository,ProjectMemberRepository}.ts +services/{AuthService,ProjectService}.ts +components/layout/{Header,Sidebar,ProjectNav}.tsx +tests/unit/..., tests/integration/..., tests/e2e/... +``` + +## Implementation Order + +1. M1 (branch feature/m1-setup): Next.js setup, deps, config, dir structure, remove boilerplate → merge to main +2. M2 (branch feature/m2-db-foundation): SQL wrapper, migrator, schema, types, migration API, unit tests → merge to main +3. M3 (branch feature/m3-auth-user): UserRepository, AuthService, session, validators, auth API, login/profile pages, unit + e2e → merge to main +4. M4 (branch feature/m4-project-member): Project/Member repos, ProjectService, project API, dashboard/members/settings pages, unit + integration + e2e → merge to main + +## Security Considerations + +- Passwords hashed with bcrypt (never plaintext) +- All SQL parameter-bound (no string concatenation) +- Auth required on all protected routes (except /api/auth/register, /api/auth/login) +- Project access requires isMember check +- Admin features require role='system_admin' +- .env not committed; secrets via environment + +## Performance Considerations + +- SQLite WAL mode for read/write concurrency +- Pagination on list endpoints +- Singleton DB connection + +## Future Extensibility + +- Repository pattern allows adding tables without changing wrapper +- SseEvent type extensible (M8) +- NotificationService/ActivityLogService hooks prepared for M5 (ProjectService.addMember will call notification in M5) diff --git a/.steering/20260624-m1-m4-foundation/requirements.md b/.steering/20260624-m1-m4-foundation/requirements.md new file mode 100644 index 0000000..7095d08 --- /dev/null +++ b/.steering/20260624-m1-m4-foundation/requirements.md @@ -0,0 +1,102 @@ +# Requirements + +## Overview + +Implement milestones M1-M4 of the Simple Groupware project: project foundation setup (Next.js 15), DB foundation (SQL wrapper + Migration), authentication & user management, and project & member management. Each milestone is developed on a separate branch and merged to main. Unit tests and E2E tests are mandatory. + +## Background + +The repository is currently a spec-driven-development boilerplate (vitest/eslint/prettier/husky configured, but no Next.js, no react, no SQLite). It must be transformed into the foundation of the Simple Groupware: a Next.js 15 + TypeScript + SQLite(better-sqlite3) application following the layered architecture (UI → Service → Repository → Data) defined in `docs/architecture.md`. M1-M4 establish the base that all subsequent milestones (M5+) depend on. + +## Features to Implement + +### 1. M1: Project Foundation Setup +- Next.js 15 (App Router, TypeScript) project configuration +- Tailwind CSS setup +- tsconfig with `@/*` path alias, strict mode +- next.config, .env.example +- Directory structure: app/, lib/, repositories/, services/, components/, tests/, data/, backups/ +- ESLint (with Next.js plugin), Prettier, Husky, lint-staged, Vitest, Playwright setup +- CI config (GitHub Actions) +- package.json scripts: lint, format, typecheck, test, test:e2e, migrate, dev, build +- Dependencies: next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize, tailwindcss, @types/better-sqlite3 +- Remove boilerplate src/example.ts and src/example.test.ts + +### 2. M2: DB Foundation (SQL Wrapper + Migration) +- `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON) +- `lib/db/migrator.ts`: Migrator class (filename-order execution, skip applied, 1-file-1-transaction, rollback on failure) +- `lib/db/migrations/001_initial.sql`: all 16 tables + indexes +- `lib/types/`: all Entity type definitions + enums +- `lib/db/run-migrations.ts`: migration runner script +- API: `GET /api/admin/migrations` (admin-only migration status) +- Unit tests: sqlite.test.ts, migrator.test.ts + +### 3. M3: Authentication & User Management +- UserRepository (findById/findByEmail/create/update) +- AuthService (register/login/logout/getCurrentUser/updateProfile) with bcrypt password hashing +- Role management (system_admin/project_admin/member/guest), account enable/disable +- lib/auth/ (session.ts, getCurrentUser.ts) +- lib/validators/userValidator.ts +- API: register, login, logout, me, users/me +- Login page, profile page, root layout +- Auth middleware (redirect unauthenticated to login) +- Unit tests: UserRepository, AuthService; E2E test: auth.spec.ts + +### 4. M4: Project & Member Management +- ProjectRepository, ProjectMemberRepository +- ProjectService (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard) with permission checks +- projectValidator.ts +- API: projects CRUD, members CRUD +- Dashboard (project list), project overview, members page, settings page +- Layout components (Header, Sidebar, ProjectNav) +- Unit tests: ProjectRepository, ProjectMemberRepository, ProjectService; Integration test: project-member-permission; E2E test: project-management.spec.ts + +## Acceptance Criteria + +### M1 +- [ ] `npm run dev` starts the Next.js dev server +- [ ] `npm run lint`, `npm run typecheck`, `npm run build` succeed +- [ ] Directory structure matches repository-structure.md + +### M2 +- [ ] `npm run migrate` creates the initial schema +- [ ] Migrations run in filename order and skip applied ones +- [ ] Failed migration rolls back +- [ ] Unit tests pass + +### M3 +- [ ] User registration, login, logout work +- [ ] Password is hashed with bcrypt +- [ ] Profile & avatar editable +- [ ] Inactive accounts cannot log in +- [ ] Unauthenticated access to protected screens is blocked +- [ ] Unit & E2E tests pass + +### M4 +- [ ] Project create/edit/delete/archive work +- [ ] Member add/remove/role work +- [ ] Non-members get 403 +- [ ] Unit, Integration & E2E tests pass + +## Success Metrics + +- All Unit tests pass (`npm test`) +- All E2E tests pass (`npm run test:e2e`) +- Lint and typecheck clean +- Each milestone merged to main on its own branch + +## Out of Scope + +The following will not be implemented in this phase: +- M5-M15 (notifications, board, chat, SSE, todo, files, calendar, milestones, meetings, search, dashboard completion, backup) +- E2E tests for features beyond M3/M4 scope (board, chat, etc.) +- Full project dashboard content (only skeleton in M4; full content in M13) + +## Reference Documents + +- `docs/product-requirements.md` - Product Requirements Document +- `docs/functional-design.md` - Functional design document +- `docs/architecture.md` - Architecture design document +- `docs/repository-structure.md` - Repository structure document +- `docs/development-guidelines.md` - Development guidelines +- `docs/milestones.md` - Milestone & task definitions diff --git a/.steering/20260624-m1-m4-foundation/tasklist.md b/.steering/20260624-m1-m4-foundation/tasklist.md new file mode 100644 index 0000000..bf59232 --- /dev/null +++ b/.steering/20260624-m1-m4-foundation/tasklist.md @@ -0,0 +1,150 @@ +# Task List + +## 🚨 Principle of Fully Completing Tasks + +**Keep working until all tasks in this file are complete** + +### Mandatory Rules +- **Make every task `[x]`** +- "Planned as a separate task due to time constraints" is forbidden +- "Postponed because the implementation is too complex" is forbidden +- Do not finish work while leaving incomplete tasks (`[ ]`) + +### The Only Case Where Skipping a Task Is Permitted +Skipping is possible only when one of the following technical reasons applies: +- A change in the implementation approach made the feature itself unnecessary +- An architecture change replaced it with a different implementation method +- A change in dependencies made the task impossible to execute + +When skipping, always state the reason clearly: +```markdown +- [x] ~~task name~~ (unnecessary due to a change in approach: specific technical reason) +``` + +### If a Task Is Too Large +- Split the task into smaller subtasks +- Add the split subtasks to this file +- Complete the subtasks one by one + +--- + +## Phase 1: M1 - Project Foundation Setup (branch: feature/m1-setup) + +- [x] Create branch `feature/m1-setup` from main +- [x] Update package.json: name, scripts (dev/build/lint/format/typecheck/test/test:e2e/migrate), dependencies (next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize), devDependencies (tailwindcss, @types/better-sqlite3, @types/bcrypt, @playwright/test, tsx, eslint-config-next) +- [x] Run `npm install` and confirm dependencies install successfully +- [x] Configure Next.js: create next.config.mjs (Node.js runtime), update tsconfig.json for Next.js (@/* path alias, jsx preserve, next plugin types), create app/globals.css (Tailwind directives), create tailwind.config.ts, postcss.config.mjs +- [x] Create Next.js app structure: app/layout.tsx (root layout), app/page.tsx (home redirect), app/globals.css +- [x] Create directory structure: lib/, repositories/, services/, components/, tests/unit, tests/integration, tests/e2e, data/.gitkeep, backups/.gitkeep +- [x] Remove boilerplate: delete src/example.ts, src/example.test.ts +- [x] Update vitest.config.ts (include tests/**, exclude .next), create playwright.config.ts +- [x] Update eslint.config.js (add Next.js plugin/ignores for .next), update .prettierrc if needed +- [x] Create .env.example (SQLITE_PATH, SESSION_SECRET) +- [x] Create CI config .github/workflows/ci.yml (lint, typecheck, test, build) +- [x] Commit M1, merge feature/m1-setup to main, push + +## Phase 2: M2 - DB Foundation (branch: feature/m2-db-foundation) + +- [ ] Create branch `feature/m2-db-foundation` from main +- [ ] Implement `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON) +- [ ] Implement `lib/db/migrator.ts`: Migrator class (schema_migrations table, filename-order, skip applied, 1-file-1-tx, rollback) +- [ ] Create `lib/db/migrations/001_initial.sql`: all 16 tables + indexes +- [ ] Create `lib/types/`: all Entity types + enums (User, Project, ProjectMember, BoardThread, BoardComment, ChatMessage, TodoColumn, TodoItem, FileAsset, ProjectNote, Milestone, CalendarEvent, Meeting, MeetingMember, Notification, ActivityLog, SchemaMigration + union types) +- [ ] Create `lib/db/run-migrations.ts`: migration runner script +- [ ] Implement API `GET /api/admin/migrations` (migration status, admin-only) +- [ ] Create test helper `tests/helpers/db.ts` (temp SQLite DB factory) +- [ ] Write Unit test `tests/unit/lib/db/sqlite.test.ts` +- [ ] Write Unit test `tests/unit/lib/db/migrator.test.ts` +- [ ] Run `npm run migrate` to verify schema creation +- [ ] Commit M2, merge feature/m2-db-foundation to main, push + +## Phase 3: M3 - Auth & User Management (branch: feature/m3-auth-user) + +- [ ] Create branch `feature/m3-auth-user` from main +- [ ] Create custom error classes `lib/errors.ts` (ValidationError, ForbiddenError, NotFoundError) +- [ ] Implement `lib/auth/session.ts` (session cookie read/write) and `lib/auth/getCurrentUser.ts` +- [ ] Implement `lib/validators/userValidator.ts` +- [ ] Implement `repositories/UserRepository.ts` +- [ ] Implement `services/AuthService.ts` (register/login/logout/getCurrentUser/updateProfile, bcrypt) +- [ ] Implement API routes: register, login, logout, me, users/me +- [ ] Implement auth middleware/guard (protected routes redirect to login) +- [ ] Implement `app/login/page.tsx`, `app/profile/page.tsx`, update `app/layout.tsx` +- [ ] Write Unit test `tests/unit/repositories/UserRepository.test.ts` +- [ ] Write Unit test `tests/unit/services/AuthService.test.ts` +- [ ] Write E2E test `tests/e2e/auth.spec.ts` +- [ ] Commit M3, merge feature/m3-auth-user to main, push + +## Phase 4: M4 - Project & Member Management (branch: feature/m4-project-member) + +- [ ] Create branch `feature/m4-project-member` from main +- [ ] Implement `lib/validators/projectValidator.ts` +- [ ] Implement `repositories/ProjectRepository.ts` +- [ ] Implement `repositories/ProjectMemberRepository.ts` +- [ ] Implement `services/ProjectService.ts` (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard skeleton, permission checks) +- [ ] Implement API routes: projects (list/create), projects/[projectId] (detail/edit/delete), members (list/add), members/[userId] (remove) +- [ ] Implement `app/dashboard/page.tsx` (project list skeleton) +- [ ] Implement `app/projects/[projectId]/page.tsx` (overview skeleton) +- [ ] Implement `app/projects/[projectId]/members/page.tsx` +- [ ] Implement `app/projects/[projectId]/settings/page.tsx` +- [ ] Implement layout components: components/layout/Header.tsx, Sidebar.tsx, ProjectNav.tsx +- [ ] Write Unit test `tests/unit/repositories/ProjectRepository.test.ts` +- [ ] Write Unit test `tests/unit/repositories/ProjectMemberRepository.test.ts` +- [ ] Write Unit test `tests/unit/services/ProjectService.test.ts` +- [ ] Write Integration test `tests/integration/project-member-permission.test.ts` +- [ ] Write E2E test `tests/e2e/project-management.spec.ts` +- [ ] Commit M4, merge feature/m4-project-member to main, push + +## Phase 5: Quality Check and Fixes + +- [ ] Confirm that all tests pass + - [ ] `npm test` +- [ ] Confirm that there are no lint errors + - [ ] `npm run lint` +- [ ] Confirm that there are no type errors + - [ ] `npm run typecheck` +- [ ] Confirm that the build succeeds + - [ ] `npm run build` + +## Phase 6: Documentation Updates + +- [ ] Update README.md (setup instructions, scripts) +- [ ] Post-implementation retrospective (record at the bottom of this file) + +--- + +## Post-Implementation Retrospective + +### Implementation Completion Date +{YYYY-MM-DD} + +### Differences Between Plan and Actual + +**Points that differed from the plan**: +- {Technical changes not anticipated at planning time} +- {Changes in the implementation approach and the reasons} + +**Tasks that became newly necessary**: +- {Tasks added during implementation} +- {Why the addition was necessary} + +**Tasks skipped for technical reasons** (only when applicable): +- {Task name} + - Reason for skipping: {specific technical reason} + - Alternative implementation: {what it was replaced with} + +**⚠️ Note**: Do not list tasks skipped for reasons such as "time constraints" or "difficulty" here. Completing all tasks is the principle. + +### Lessons Learned + +**Technical insights**: +- {Technical knowledge gained through implementation} +- {New technologies or patterns used} + +**Process improvements**: +- {What went well in task management} +- {How the steering files were leveraged} + +### Improvement Suggestions for Next Time +- {Things to watch out for in the next feature addition} +- {More efficient implementation methods} +- {Improvements to task planning} From d7c4cd5e5898117b0f29e94a86e9bc50449d7991 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 00:21:26 +0200 Subject: [PATCH 3/3] feat(m2): DB foundation tests + multi-statement migration fix - Add SqliteDatabase.exec() using better-sqlite3 exec() so the migrator can run multi-statement SQL files (prepare() rejected 001_initial.sql) - Migrator now uses exec() for migration file content - Fix tests/helpers/db.ts: replace CJS require() with ESM import - Add unit tests for SqliteDatabase (query/get/execute/transaction/exec, WAL & foreign_keys pragmas) and Migrator (ordering, idempotency, rollback, real 001_initial.sql applies) - Restore .gitignore steering exclusion; remove stale steering files --- .gitignore | 3 + .../design.md | 0 .../requirements.md | 0 .../tasklist.md | 0 .steering/20260624-m1-m4-foundation/design.md | 189 ------------- .../20260624-m1-m4-foundation/requirements.md | 102 ------- .../20260624-m1-m4-foundation/tasklist.md | 150 ----------- lib/db/migrator.ts | 2 +- lib/db/sqlite.ts | 10 +- package-lock.json | 17 -- tests/helpers/db.ts | 3 +- tests/unit/lib/db/migrator.test.ts | 185 +++++++++++++ tests/unit/lib/db/sqlite.test.ts | 252 ++++++++++++++++++ 13 files changed, 451 insertions(+), 462 deletions(-) delete mode 100644 .steering/20260624-execute-m1-m4-milestones/design.md delete mode 100644 .steering/20260624-execute-m1-m4-milestones/requirements.md delete mode 100644 .steering/20260624-execute-m1-m4-milestones/tasklist.md delete mode 100644 .steering/20260624-m1-m4-foundation/design.md delete mode 100644 .steering/20260624-m1-m4-foundation/requirements.md delete mode 100644 .steering/20260624-m1-m4-foundation/tasklist.md create mode 100644 tests/unit/lib/db/migrator.test.ts create mode 100644 tests/unit/lib/db/sqlite.test.ts diff --git a/.gitignore b/.gitignore index 1a184c7..9bf0e74 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ Thumbs.db coverage/ .nyc_output/ +# Steering files (task management - temporary) +.steering/* +!.steering/.gitkeep # Lock files (keep package-lock.json for consistency) # yarn.lock diff --git a/.steering/20260624-execute-m1-m4-milestones/design.md b/.steering/20260624-execute-m1-m4-milestones/design.md deleted file mode 100644 index e69de29..0000000 diff --git a/.steering/20260624-execute-m1-m4-milestones/requirements.md b/.steering/20260624-execute-m1-m4-milestones/requirements.md deleted file mode 100644 index e69de29..0000000 diff --git a/.steering/20260624-execute-m1-m4-milestones/tasklist.md b/.steering/20260624-execute-m1-m4-milestones/tasklist.md deleted file mode 100644 index e69de29..0000000 diff --git a/.steering/20260624-m1-m4-foundation/design.md b/.steering/20260624-m1-m4-foundation/design.md deleted file mode 100644 index 0588dab..0000000 --- a/.steering/20260624-m1-m4-foundation/design.md +++ /dev/null @@ -1,189 +0,0 @@ -# Design Document - -## Architecture Overview - -Layered architecture (UI → Service → Repository → Data) per `docs/architecture.md`. Next.js 15 App Router with Node.js Runtime only (no Edge Runtime). SQLite via better-sqlite3 accessed exclusively through a custom SQL wrapper. No Prisma. - -``` -app/ (UI: Route Handlers / Server Components / SSE) - ↓ -services/ (business logic, permission checks, transactions) - ↓ -repositories/ (SQL, parameter binding, logical-delete filter) - ↓ -lib/db/ (SQLite connection, SQL wrapper, Migration) -``` - -## Component Design - -### 1. SQL Wrapper: `lib/db/sqlite.ts` - -**Responsibilities**: -- SQLite connection management (singleton getDb(), WAL, foreign_keys ON) -- query (multi-row), get (single-row), execute (insert/update/delete), transaction, close -- Error handling - -**Implementation highlights**: -- dbPath = process.env.SQLITE_PATH ?? "./data/app.db" -- SqlParams = Record | unknown[] -- All repositories use this wrapper; direct better-sqlite3 access forbidden - -### 2. Migrator: `lib/db/migrator.ts` - -**Responsibilities**: -- Create schema_migrations table -- Read .sql files from migrations dir in filename order -- Skip already-applied files (tracked in schema_migrations) -- Execute each file in a transaction; rollback on failure - -### 3. Initial Schema: `lib/db/migrations/001_initial.sql` - -16 tables: users, projects, project_members, board_threads, board_comments, chat_messages, todo_columns, todo_items, file_assets, project_notes, milestones, calendar_events, meetings, meeting_members, notifications, activity_logs + indexes. - -### 4. Entity Types: `lib/types/` - -PascalCase type files for each entity + union types (UserRole, UserStatus, ProjectStatus, ProjectMemberRole, etc.). - -### 5. UserRepository - -findById, findByEmail, create (with passwordHash), update (name/avatarUrl/role/status). - -### 6. AuthService - -register (hash password, create user), login (verify hash, issue session), logout, getCurrentUser, updateProfile. Uses bcrypt. - -### 7. Session: `lib/auth/session.ts`, `lib/auth/getCurrentUser.ts` - -Cookie-based session. getCurrentUser resolves the user from the request cookie. - -### 8. ProjectRepository / ProjectMemberRepository - -Project: findById, findByOwner, findByUser (via join), create, update, delete. -ProjectMember: findByProject, findByUser, add, remove, isMember, getRole. - -### 9. ProjectService - -createProject (owner becomes admin member), updateProject, addMember (permission check + notification hook later), removeMember, archiveProject, getDashboard skeleton. - -## Data Flow - -### Login -1. POST /api/auth/login → AuthService.login(email, password) -2. AuthService → UserRepository.findByEmail → verify bcrypt hash -3. Issue session cookie → return user - -### Project creation -1. POST /api/projects → ProjectService.createProject(actorId, input) -2. ProjectService → permission (any authenticated user) → ProjectRepository.create -3. ProjectMemberRepository.add(projectId, ownerId, 'admin') -4. Return project - -## Error Handling Strategy - -### Custom Error Classes -- ValidationError (400) - field, value -- ForbiddenError (403) -- NotFoundError (404) - resource, id -- (409 for unique constraint - handled via SQLite error code) - -### Error Handling Patterns -Route Handlers catch errors and map to HTTP status. Expected errors use custom classes; unexpected errors propagate + log. - -## Test Strategy - -### Unit Tests (Vitest) -- lib/db/sqlite.test.ts (query/get/execute/transaction, WAL, foreign keys) -- lib/db/migrator.test.ts (order, skip applied, rollback) -- repositories/UserRepository.test.ts -- services/AuthService.test.ts -- repositories/ProjectRepository.test.ts -- repositories/ProjectMemberRepository.test.ts -- services/ProjectService.test.ts - -Tests use a temp SQLite file (in-memory or tmpdir) to avoid touching real data/. - -### Integration Tests (Vitest) -- project-member-permission.test.ts (non-member cannot access project data) - -### E2E Tests (Playwright) -- auth.spec.ts (login, logout, protected screen) -- project-management.spec.ts (create, edit, member add/remove, archive) - -## Dependent Libraries - -```json -{ - "dependencies": { - "next": "15", - "react": "19", - "react-dom": "19", - "better-sqlite3": "^11", - "bcrypt": "^5", - "react-markdown": "^9", - "remark-gfm": "^4", - "rehype-sanitize": "^6" - }, - "devDependencies": { - "tailwindcss": "^3", - "@types/better-sqlite3": "^7", - "@types/bcrypt": "^5", - "@playwright/test": "^1", - "tsx": "^4" - } -} -``` - -## Directory Structure - -``` -app/ - api/auth/{register,login,logout,me}/route.ts - api/users/me/route.ts - api/projects/route.ts - api/projects/[projectId]/route.ts - api/projects/[projectId]/members/route.ts - api/projects/[projectId]/members/[userId]/route.ts - api/admin/migrations/route.ts - login/page.tsx - profile/page.tsx - dashboard/page.tsx - projects/[projectId]/{page,members,settings}.tsx - layout.tsx, globals.css -lib/ - db/{sqlite.ts, migrator.ts, run-migrations.ts, migrations/001_initial.sql} - auth/{session.ts, getCurrentUser.ts} - types/*.ts - validators/{userValidator.ts, projectValidator.ts} -repositories/{UserRepository,ProjectRepository,ProjectMemberRepository}.ts -services/{AuthService,ProjectService}.ts -components/layout/{Header,Sidebar,ProjectNav}.tsx -tests/unit/..., tests/integration/..., tests/e2e/... -``` - -## Implementation Order - -1. M1 (branch feature/m1-setup): Next.js setup, deps, config, dir structure, remove boilerplate → merge to main -2. M2 (branch feature/m2-db-foundation): SQL wrapper, migrator, schema, types, migration API, unit tests → merge to main -3. M3 (branch feature/m3-auth-user): UserRepository, AuthService, session, validators, auth API, login/profile pages, unit + e2e → merge to main -4. M4 (branch feature/m4-project-member): Project/Member repos, ProjectService, project API, dashboard/members/settings pages, unit + integration + e2e → merge to main - -## Security Considerations - -- Passwords hashed with bcrypt (never plaintext) -- All SQL parameter-bound (no string concatenation) -- Auth required on all protected routes (except /api/auth/register, /api/auth/login) -- Project access requires isMember check -- Admin features require role='system_admin' -- .env not committed; secrets via environment - -## Performance Considerations - -- SQLite WAL mode for read/write concurrency -- Pagination on list endpoints -- Singleton DB connection - -## Future Extensibility - -- Repository pattern allows adding tables without changing wrapper -- SseEvent type extensible (M8) -- NotificationService/ActivityLogService hooks prepared for M5 (ProjectService.addMember will call notification in M5) diff --git a/.steering/20260624-m1-m4-foundation/requirements.md b/.steering/20260624-m1-m4-foundation/requirements.md deleted file mode 100644 index 7095d08..0000000 --- a/.steering/20260624-m1-m4-foundation/requirements.md +++ /dev/null @@ -1,102 +0,0 @@ -# Requirements - -## Overview - -Implement milestones M1-M4 of the Simple Groupware project: project foundation setup (Next.js 15), DB foundation (SQL wrapper + Migration), authentication & user management, and project & member management. Each milestone is developed on a separate branch and merged to main. Unit tests and E2E tests are mandatory. - -## Background - -The repository is currently a spec-driven-development boilerplate (vitest/eslint/prettier/husky configured, but no Next.js, no react, no SQLite). It must be transformed into the foundation of the Simple Groupware: a Next.js 15 + TypeScript + SQLite(better-sqlite3) application following the layered architecture (UI → Service → Repository → Data) defined in `docs/architecture.md`. M1-M4 establish the base that all subsequent milestones (M5+) depend on. - -## Features to Implement - -### 1. M1: Project Foundation Setup -- Next.js 15 (App Router, TypeScript) project configuration -- Tailwind CSS setup -- tsconfig with `@/*` path alias, strict mode -- next.config, .env.example -- Directory structure: app/, lib/, repositories/, services/, components/, tests/, data/, backups/ -- ESLint (with Next.js plugin), Prettier, Husky, lint-staged, Vitest, Playwright setup -- CI config (GitHub Actions) -- package.json scripts: lint, format, typecheck, test, test:e2e, migrate, dev, build -- Dependencies: next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize, tailwindcss, @types/better-sqlite3 -- Remove boilerplate src/example.ts and src/example.test.ts - -### 2. M2: DB Foundation (SQL Wrapper + Migration) -- `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON) -- `lib/db/migrator.ts`: Migrator class (filename-order execution, skip applied, 1-file-1-transaction, rollback on failure) -- `lib/db/migrations/001_initial.sql`: all 16 tables + indexes -- `lib/types/`: all Entity type definitions + enums -- `lib/db/run-migrations.ts`: migration runner script -- API: `GET /api/admin/migrations` (admin-only migration status) -- Unit tests: sqlite.test.ts, migrator.test.ts - -### 3. M3: Authentication & User Management -- UserRepository (findById/findByEmail/create/update) -- AuthService (register/login/logout/getCurrentUser/updateProfile) with bcrypt password hashing -- Role management (system_admin/project_admin/member/guest), account enable/disable -- lib/auth/ (session.ts, getCurrentUser.ts) -- lib/validators/userValidator.ts -- API: register, login, logout, me, users/me -- Login page, profile page, root layout -- Auth middleware (redirect unauthenticated to login) -- Unit tests: UserRepository, AuthService; E2E test: auth.spec.ts - -### 4. M4: Project & Member Management -- ProjectRepository, ProjectMemberRepository -- ProjectService (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard) with permission checks -- projectValidator.ts -- API: projects CRUD, members CRUD -- Dashboard (project list), project overview, members page, settings page -- Layout components (Header, Sidebar, ProjectNav) -- Unit tests: ProjectRepository, ProjectMemberRepository, ProjectService; Integration test: project-member-permission; E2E test: project-management.spec.ts - -## Acceptance Criteria - -### M1 -- [ ] `npm run dev` starts the Next.js dev server -- [ ] `npm run lint`, `npm run typecheck`, `npm run build` succeed -- [ ] Directory structure matches repository-structure.md - -### M2 -- [ ] `npm run migrate` creates the initial schema -- [ ] Migrations run in filename order and skip applied ones -- [ ] Failed migration rolls back -- [ ] Unit tests pass - -### M3 -- [ ] User registration, login, logout work -- [ ] Password is hashed with bcrypt -- [ ] Profile & avatar editable -- [ ] Inactive accounts cannot log in -- [ ] Unauthenticated access to protected screens is blocked -- [ ] Unit & E2E tests pass - -### M4 -- [ ] Project create/edit/delete/archive work -- [ ] Member add/remove/role work -- [ ] Non-members get 403 -- [ ] Unit, Integration & E2E tests pass - -## Success Metrics - -- All Unit tests pass (`npm test`) -- All E2E tests pass (`npm run test:e2e`) -- Lint and typecheck clean -- Each milestone merged to main on its own branch - -## Out of Scope - -The following will not be implemented in this phase: -- M5-M15 (notifications, board, chat, SSE, todo, files, calendar, milestones, meetings, search, dashboard completion, backup) -- E2E tests for features beyond M3/M4 scope (board, chat, etc.) -- Full project dashboard content (only skeleton in M4; full content in M13) - -## Reference Documents - -- `docs/product-requirements.md` - Product Requirements Document -- `docs/functional-design.md` - Functional design document -- `docs/architecture.md` - Architecture design document -- `docs/repository-structure.md` - Repository structure document -- `docs/development-guidelines.md` - Development guidelines -- `docs/milestones.md` - Milestone & task definitions diff --git a/.steering/20260624-m1-m4-foundation/tasklist.md b/.steering/20260624-m1-m4-foundation/tasklist.md deleted file mode 100644 index bf59232..0000000 --- a/.steering/20260624-m1-m4-foundation/tasklist.md +++ /dev/null @@ -1,150 +0,0 @@ -# Task List - -## 🚨 Principle of Fully Completing Tasks - -**Keep working until all tasks in this file are complete** - -### Mandatory Rules -- **Make every task `[x]`** -- "Planned as a separate task due to time constraints" is forbidden -- "Postponed because the implementation is too complex" is forbidden -- Do not finish work while leaving incomplete tasks (`[ ]`) - -### The Only Case Where Skipping a Task Is Permitted -Skipping is possible only when one of the following technical reasons applies: -- A change in the implementation approach made the feature itself unnecessary -- An architecture change replaced it with a different implementation method -- A change in dependencies made the task impossible to execute - -When skipping, always state the reason clearly: -```markdown -- [x] ~~task name~~ (unnecessary due to a change in approach: specific technical reason) -``` - -### If a Task Is Too Large -- Split the task into smaller subtasks -- Add the split subtasks to this file -- Complete the subtasks one by one - ---- - -## Phase 1: M1 - Project Foundation Setup (branch: feature/m1-setup) - -- [x] Create branch `feature/m1-setup` from main -- [x] Update package.json: name, scripts (dev/build/lint/format/typecheck/test/test:e2e/migrate), dependencies (next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize), devDependencies (tailwindcss, @types/better-sqlite3, @types/bcrypt, @playwright/test, tsx, eslint-config-next) -- [x] Run `npm install` and confirm dependencies install successfully -- [x] Configure Next.js: create next.config.mjs (Node.js runtime), update tsconfig.json for Next.js (@/* path alias, jsx preserve, next plugin types), create app/globals.css (Tailwind directives), create tailwind.config.ts, postcss.config.mjs -- [x] Create Next.js app structure: app/layout.tsx (root layout), app/page.tsx (home redirect), app/globals.css -- [x] Create directory structure: lib/, repositories/, services/, components/, tests/unit, tests/integration, tests/e2e, data/.gitkeep, backups/.gitkeep -- [x] Remove boilerplate: delete src/example.ts, src/example.test.ts -- [x] Update vitest.config.ts (include tests/**, exclude .next), create playwright.config.ts -- [x] Update eslint.config.js (add Next.js plugin/ignores for .next), update .prettierrc if needed -- [x] Create .env.example (SQLITE_PATH, SESSION_SECRET) -- [x] Create CI config .github/workflows/ci.yml (lint, typecheck, test, build) -- [x] Commit M1, merge feature/m1-setup to main, push - -## Phase 2: M2 - DB Foundation (branch: feature/m2-db-foundation) - -- [ ] Create branch `feature/m2-db-foundation` from main -- [ ] Implement `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON) -- [ ] Implement `lib/db/migrator.ts`: Migrator class (schema_migrations table, filename-order, skip applied, 1-file-1-tx, rollback) -- [ ] Create `lib/db/migrations/001_initial.sql`: all 16 tables + indexes -- [ ] Create `lib/types/`: all Entity types + enums (User, Project, ProjectMember, BoardThread, BoardComment, ChatMessage, TodoColumn, TodoItem, FileAsset, ProjectNote, Milestone, CalendarEvent, Meeting, MeetingMember, Notification, ActivityLog, SchemaMigration + union types) -- [ ] Create `lib/db/run-migrations.ts`: migration runner script -- [ ] Implement API `GET /api/admin/migrations` (migration status, admin-only) -- [ ] Create test helper `tests/helpers/db.ts` (temp SQLite DB factory) -- [ ] Write Unit test `tests/unit/lib/db/sqlite.test.ts` -- [ ] Write Unit test `tests/unit/lib/db/migrator.test.ts` -- [ ] Run `npm run migrate` to verify schema creation -- [ ] Commit M2, merge feature/m2-db-foundation to main, push - -## Phase 3: M3 - Auth & User Management (branch: feature/m3-auth-user) - -- [ ] Create branch `feature/m3-auth-user` from main -- [ ] Create custom error classes `lib/errors.ts` (ValidationError, ForbiddenError, NotFoundError) -- [ ] Implement `lib/auth/session.ts` (session cookie read/write) and `lib/auth/getCurrentUser.ts` -- [ ] Implement `lib/validators/userValidator.ts` -- [ ] Implement `repositories/UserRepository.ts` -- [ ] Implement `services/AuthService.ts` (register/login/logout/getCurrentUser/updateProfile, bcrypt) -- [ ] Implement API routes: register, login, logout, me, users/me -- [ ] Implement auth middleware/guard (protected routes redirect to login) -- [ ] Implement `app/login/page.tsx`, `app/profile/page.tsx`, update `app/layout.tsx` -- [ ] Write Unit test `tests/unit/repositories/UserRepository.test.ts` -- [ ] Write Unit test `tests/unit/services/AuthService.test.ts` -- [ ] Write E2E test `tests/e2e/auth.spec.ts` -- [ ] Commit M3, merge feature/m3-auth-user to main, push - -## Phase 4: M4 - Project & Member Management (branch: feature/m4-project-member) - -- [ ] Create branch `feature/m4-project-member` from main -- [ ] Implement `lib/validators/projectValidator.ts` -- [ ] Implement `repositories/ProjectRepository.ts` -- [ ] Implement `repositories/ProjectMemberRepository.ts` -- [ ] Implement `services/ProjectService.ts` (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard skeleton, permission checks) -- [ ] Implement API routes: projects (list/create), projects/[projectId] (detail/edit/delete), members (list/add), members/[userId] (remove) -- [ ] Implement `app/dashboard/page.tsx` (project list skeleton) -- [ ] Implement `app/projects/[projectId]/page.tsx` (overview skeleton) -- [ ] Implement `app/projects/[projectId]/members/page.tsx` -- [ ] Implement `app/projects/[projectId]/settings/page.tsx` -- [ ] Implement layout components: components/layout/Header.tsx, Sidebar.tsx, ProjectNav.tsx -- [ ] Write Unit test `tests/unit/repositories/ProjectRepository.test.ts` -- [ ] Write Unit test `tests/unit/repositories/ProjectMemberRepository.test.ts` -- [ ] Write Unit test `tests/unit/services/ProjectService.test.ts` -- [ ] Write Integration test `tests/integration/project-member-permission.test.ts` -- [ ] Write E2E test `tests/e2e/project-management.spec.ts` -- [ ] Commit M4, merge feature/m4-project-member to main, push - -## Phase 5: Quality Check and Fixes - -- [ ] Confirm that all tests pass - - [ ] `npm test` -- [ ] Confirm that there are no lint errors - - [ ] `npm run lint` -- [ ] Confirm that there are no type errors - - [ ] `npm run typecheck` -- [ ] Confirm that the build succeeds - - [ ] `npm run build` - -## Phase 6: Documentation Updates - -- [ ] Update README.md (setup instructions, scripts) -- [ ] Post-implementation retrospective (record at the bottom of this file) - ---- - -## Post-Implementation Retrospective - -### Implementation Completion Date -{YYYY-MM-DD} - -### Differences Between Plan and Actual - -**Points that differed from the plan**: -- {Technical changes not anticipated at planning time} -- {Changes in the implementation approach and the reasons} - -**Tasks that became newly necessary**: -- {Tasks added during implementation} -- {Why the addition was necessary} - -**Tasks skipped for technical reasons** (only when applicable): -- {Task name} - - Reason for skipping: {specific technical reason} - - Alternative implementation: {what it was replaced with} - -**⚠️ Note**: Do not list tasks skipped for reasons such as "time constraints" or "difficulty" here. Completing all tasks is the principle. - -### Lessons Learned - -**Technical insights**: -- {Technical knowledge gained through implementation} -- {New technologies or patterns used} - -**Process improvements**: -- {What went well in task management} -- {How the steering files were leveraged} - -### Improvement Suggestions for Next Time -- {Things to watch out for in the next feature addition} -- {More efficient implementation methods} -- {Improvements to task planning} diff --git a/lib/db/migrator.ts b/lib/db/migrator.ts index 431d9f6..20398c9 100644 --- a/lib/db/migrator.ts +++ b/lib/db/migrator.ts @@ -37,7 +37,7 @@ export class Migrator { const sql = fs.readFileSync(fullPath, 'utf-8'); this.db.transaction(() => { - this.db.execute(sql); + this.db.exec(sql); this.db.execute( `INSERT INTO schema_migrations (filename, applied_at) VALUES (@filename, @appliedAt)`, { filename: file, appliedAt: new Date().toISOString() } diff --git a/lib/db/sqlite.ts b/lib/db/sqlite.ts index 6f04bfa..bf6a466 100644 --- a/lib/db/sqlite.ts +++ b/lib/db/sqlite.ts @@ -37,7 +37,7 @@ export class SqliteDatabase { } /** - * INSERT / UPDATE / DELETEを実行する + * INSERT / UPDATE / DELETEを実行する(単一プリペアドステートメント) */ execute(sql: string, params?: SqlParams): ExecuteResult { const result = this.db.prepare(sql).run(params ?? {}); @@ -48,6 +48,14 @@ export class SqliteDatabase { }; } + /** + * 複数ステートメントを含むSQLを一括実行する(Migration用・パラメータバインド不可)。 + * better-sqlite3の exec() を利用し、セミコロン区切りの複数文・コメントを処理する。 + */ + exec(sql: string): void { + this.db.exec(sql); + } + /** * トランザクション内でコールバックを実行する */ diff --git a/package-lock.json b/package-lock.json index b2642d9..d5cdd16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -133,7 +133,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -145,7 +144,6 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1643,7 +1641,6 @@ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.61.1" }, @@ -2142,7 +2139,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2208,7 +2204,6 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -2913,7 +2908,6 @@ "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "2.1.9", "fflate": "^0.8.2", @@ -2957,7 +2951,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3524,7 +3517,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4511,7 +4503,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6497,7 +6488,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8637,7 +8627,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -8948,7 +8937,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8958,7 +8946,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10540,7 +10527,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10672,7 +10658,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -10810,7 +10795,6 @@ "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11603,7 +11587,6 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index aa9eee9..72d71e7 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -2,6 +2,7 @@ import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; import { SqliteDatabase } from '@/lib/db/sqlite'; +import { Migrator } from '@/lib/db/migrator'; let counter = 0; @@ -38,8 +39,6 @@ export function createTestDb(): SqliteDatabase { 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; diff --git a/tests/unit/lib/db/migrator.test.ts b/tests/unit/lib/db/migrator.test.ts new file mode 100644 index 0000000..c25f102 --- /dev/null +++ b/tests/unit/lib/db/migrator.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createTestDb } from '@/tests/helpers/db'; +import { Migrator } from '@/lib/db/migrator'; +import { SqliteDatabase } from '@/lib/db/sqlite'; + +function createTempMigrationsDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'migrations-')); +} + +function writeMigration(dir: string, filename: string, sql: string): void { + fs.writeFileSync(path.join(dir, filename), sql, 'utf-8'); +} + +describe('Migrator', () => { + let db: SqliteDatabase; + let migrationsDir: string; + + beforeEach(() => { + db = createTestDb(); + migrationsDir = createTempMigrationsDir(); + }); + + afterEach(() => { + db.close(); + fs.rmSync(migrationsDir, { recursive: true, force: true }); + }); + + it('creates the schema_migrations table', () => { + writeMigration( + migrationsDir, + '001_init.sql', + 'CREATE TABLE sample (id INTEGER);' + ); + const migrator = new Migrator(db, migrationsDir); + + migrator.migrate(); + + const row = db.get<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'" + ); + expect(row?.name).toBe('schema_migrations'); + }); + + it('applies migration files in filename order', () => { + writeMigration( + migrationsDir, + '002_second.sql', + 'CREATE TABLE second (id INTEGER);' + ); + writeMigration( + migrationsDir, + '001_first.sql', + 'CREATE TABLE first (id INTEGER);' + ); + const migrator = new Migrator(db, migrationsDir); + + migrator.migrate(); + + const applied = migrator.getAppliedMigrations(); + expect(applied.map((m) => m.filename)).toEqual([ + '001_first.sql', + '002_second.sql', + ]); + expect( + db.get<{ name: string }>( + "SELECT name FROM sqlite_master WHERE name='first'" + ) + ).not.toBeNull(); + expect( + db.get<{ name: string }>( + "SELECT name FROM sqlite_master WHERE name='second'" + ) + ).not.toBeNull(); + }); + + it('skips already-applied migrations on subsequent runs', () => { + writeMigration( + migrationsDir, + '001_init.sql', + 'CREATE TABLE sample (id INTEGER);' + ); + const migrator = new Migrator(db, migrationsDir); + + migrator.migrate(); + expect(migrator.getAppliedMigrations()).toHaveLength(1); + + expect(() => migrator.migrate()).not.toThrow(); + expect(migrator.getAppliedMigrations()).toHaveLength(1); + }); + + it('rolls back and does not record a migration when its SQL fails', () => { + writeMigration( + migrationsDir, + '001_bad.sql', + 'CREATE TABLE will_exist (id INTEGER); NOT A VALID STATEMENT;' + ); + const migrator = new Migrator(db, migrationsDir); + + expect(() => migrator.migrate()).toThrow(); + + expect(migrator.getAppliedMigrations()).toHaveLength(0); + expect( + db.get<{ name: string }>( + "SELECT name FROM sqlite_master WHERE name='will_exist'" + ) + ).toBeNull(); + }); + + it('returns applied migrations ordered by filename', () => { + writeMigration(migrationsDir, '001_a.sql', 'CREATE TABLE a (id INTEGER);'); + writeMigration(migrationsDir, '002_b.sql', 'CREATE TABLE b (id INTEGER);'); + const migrator = new Migrator(db, migrationsDir); + + migrator.migrate(); + + const applied = migrator.getAppliedMigrations(); + expect(applied).toHaveLength(2); + expect(applied[0].filename).toBe('001_a.sql'); + expect(applied[1].filename).toBe('002_b.sql'); + expect(applied[0].applied_at).toBeTruthy(); + }); + + it('only reads .sql files in the migrations directory', () => { + writeMigration( + migrationsDir, + '001_real.sql', + 'CREATE TABLE real_t (id INTEGER);' + ); + fs.writeFileSync(path.join(migrationsDir, 'README.md'), 'not a migration'); + const migrator = new Migrator(db, migrationsDir); + + migrator.migrate(); + + expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([ + '001_real.sql', + ]); + }); + + it('applies the real 001_initial.sql and creates all core tables', () => { + const realMigrationsDir = path.join( + process.cwd(), + 'lib', + 'db', + 'migrations' + ); + const migrator = new Migrator(db, realMigrationsDir); + + migrator.migrate(); + + const tables = db + .query<{ + name: string; + }>("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .map((t) => t.name); + + const expectedTables = [ + 'activity_logs', + 'board_comments', + 'board_threads', + 'calendar_events', + 'chat_messages', + 'file_assets', + 'meeting_members', + 'meetings', + 'milestones', + 'notifications', + 'project_members', + 'project_notes', + 'projects', + 'schema_migrations', + 'todo_columns', + 'todo_items', + 'users', + ]; + for (const table of expectedTables) { + expect(tables).toContain(table); + } + expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([ + '001_initial.sql', + ]); + }); +}); diff --git a/tests/unit/lib/db/sqlite.test.ts b/tests/unit/lib/db/sqlite.test.ts new file mode 100644 index 0000000..1d3d6d9 --- /dev/null +++ b/tests/unit/lib/db/sqlite.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createTestDb } from '@/tests/helpers/db'; +import { SqliteDatabase } from '@/lib/db/sqlite'; + +describe('SqliteDatabase', () => { + let db: SqliteDatabase; + + afterEach(() => { + if (db) db.close(); + }); + + describe('constructor', () => { + it('enables WAL journal mode', () => { + db = createTestDb(); + const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode'); + expect(row?.journal_mode).toBe('wal'); + }); + + it('enables foreign key constraints', () => { + db = createTestDb(); + const row = db.get<{ foreign_keys: number }>('PRAGMA foreign_keys'); + expect(row?.foreign_keys).toBe(1); + }); + }); + + describe('query', () => { + it('returns multiple rows', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + db.execute("INSERT INTO items (name) VALUES ('a')"); + db.execute("INSERT INTO items (name) VALUES ('b')"); + + const rows = db.query<{ id: number; name: string }>( + 'SELECT * FROM items ORDER BY id' + ); + + expect(rows).toHaveLength(2); + expect(rows[0].name).toBe('a'); + expect(rows[1].name).toBe('b'); + }); + + it('returns an empty array when no rows match', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + + const rows = db.query<{ id: number }>('SELECT * FROM items'); + + expect(rows).toEqual([]); + }); + + it('binds named parameters', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + db.execute("INSERT INTO items (name) VALUES ('target')"); + db.execute("INSERT INTO items (name) VALUES ('other')"); + + const rows = db.query<{ name: string }>( + 'SELECT * FROM items WHERE name = @name', + { name: 'target' } + ); + + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('target'); + }); + }); + + describe('get', () => { + it('returns a single row when found', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + const { lastInsertRowid } = db.execute( + "INSERT INTO items (name) VALUES ('x')" + ); + + const row = db.get<{ id: number; name: string }>( + 'SELECT * FROM items WHERE id = @id', + { id: Number(lastInsertRowid) } + ); + + expect(row).not.toBeNull(); + expect(row?.name).toBe('x'); + }); + + it('returns null when no row is found', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + + const row = db.get<{ id: number }>('SELECT * FROM items WHERE id = @id', { + id: 999, + }); + + expect(row).toBeNull(); + }); + }); + + describe('execute', () => { + it('returns changes and lastInsertRowid for an insert', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + + const result = db.execute("INSERT INTO items (name) VALUES ('a')"); + + expect(result.changes).toBe(1); + expect(Number(result.lastInsertRowid)).toBeGreaterThan(0); + }); + + it('returns the number of affected rows for an update', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + db.execute("INSERT INTO items (name) VALUES ('a')"); + db.execute("INSERT INTO items (name) VALUES ('b')"); + + const result = db.execute( + 'UPDATE items SET name = @name WHERE name = @old', + { name: 'updated', old: 'a' } + ); + + expect(result.changes).toBe(1); + }); + + it('returns the number of deleted rows for a delete', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + db.execute("INSERT INTO items (name) VALUES ('a')"); + + const result = db.execute('DELETE FROM items WHERE name = @name', { + name: 'a', + }); + + expect(result.changes).toBe(1); + }); + }); + + describe('exec', () => { + it('executes multiple semicolon-separated statements', () => { + db = createTestDb(); + + db.exec(` + CREATE TABLE a (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE b (id INTEGER PRIMARY KEY, value INTEGER NOT NULL); + INSERT INTO a (name) VALUES ('first'); + INSERT INTO b (value) VALUES (42); + `); + + expect(db.get<{ name: string }>('SELECT name FROM a')?.name).toBe( + 'first' + ); + expect(db.get<{ value: number }>('SELECT value FROM b')?.value).toBe(42); + }); + + it('handles SQL comments alongside statements', () => { + db = createTestDb(); + + db.exec(` + -- this is a comment + CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + /* multi-line + comment */ + INSERT INTO items (name) VALUES ('x'); + `); + + expect(db.query<{ name: string }>('SELECT name FROM items')).toHaveLength( + 1 + ); + }); + }); + + describe('transaction', () => { + it('commits changes when the callback succeeds', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + + db.transaction(() => { + db.execute("INSERT INTO items (name) VALUES ('a')"); + db.execute("INSERT INTO items (name) VALUES ('b')"); + }); + + const rows = db.query<{ name: string }>( + 'SELECT * FROM items ORDER BY id' + ); + expect(rows).toHaveLength(2); + }); + + it('rolls back changes when the callback throws', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + + expect(() => + db.transaction(() => { + db.execute("INSERT INTO items (name) VALUES ('a')"); + db.execute("INSERT INTO items (name) VALUES ('b')"); + throw new Error('boom'); + }) + ).toThrow('boom'); + + const rows = db.query<{ name: string }>('SELECT * FROM items'); + expect(rows).toHaveLength(0); + }); + + it('returns the callback value', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + + const result = db.transaction(() => { + db.execute("INSERT INTO items (name) VALUES ('a')"); + return 'committed'; + }); + + expect(result).toBe('committed'); + }); + }); + + describe('foreign key enforcement', () => { + it('rejects inserts that violate a foreign key constraint', () => { + db = createTestDb(); + db.execute( + 'CREATE TABLE parents (id INTEGER PRIMARY KEY, name TEXT NOT NULL)' + ); + db.execute( + 'CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL, FOREIGN KEY (parent_id) REFERENCES parents(id))' + ); + + expect(() => + db.execute('INSERT INTO children (parent_id) VALUES (@parentId)', { + parentId: 999, + }) + ).toThrow(); + }); + }); +});