save current changes
This commit is contained in:
257
lib/db/migrations/001_initial.sql
Normal file
257
lib/db/migrations/001_initial.sql
Normal file
@ -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);
|
||||
68
lib/db/migrator.ts
Normal file
68
lib/db/migrator.ts
Normal file
@ -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
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
26
lib/db/run-migrations.ts
Normal file
26
lib/db/run-migrations.ts
Normal file
@ -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();
|
||||
90
lib/db/sqlite.ts
Normal file
90
lib/db/sqlite.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export type SqlParams = Record<string, unknown> | 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<T>(sql: string, params?: SqlParams): T[] {
|
||||
return this.db.prepare(sql).all(params ?? {}) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 単一行を取得するSELECT(該当なしの場合はnull)
|
||||
*/
|
||||
get<T>(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<T>(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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user