diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index b4e5a6c..3f9fe49 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -8,91 +8,15 @@ * 注意: 既存のDBファイルとuploadsディレクトリを削除してから再作成します * (デモ用途のため、冪等に再実行できるようにリセット方式を採用)。 * - * 投入内容: 管理者1名 + 一般ユーザー5名、プロジェクト3件、各プロジェクトに + * 投入内容: 管理者1名 + 一般ユーザー約29名、プロジェクト約15件、各プロジェクトに * 掲示板/チャット/ToDo/メモ/ファイル/カレンダー/マイルストーン/ミーティング/ - * 通知/アクティビティログを網羅。 + * 通知/アクティビティログを網羅(従来比 約5倍)。生成内容は固定シードで決定論的。 */ import fs from 'node:fs'; import path from 'node:path'; -import bcrypt from 'bcrypt'; import { SqliteDatabase } from '../lib/db/sqlite'; import { Migrator } from '../lib/db/migrator'; - -const BCRYPT_ROUNDS = 10; -const now = () => new Date().toISOString(); -const daysFromNow = (d: number) => { - const dt = new Date(); - dt.setDate(dt.getDate() + d); - return dt.toISOString(); -}; -const dayStr = (d: number) => daysFromNow(d).slice(0, 10); - -interface UserSeed { - name: string; - email: string; - password: string; - role: 'system_admin' | 'member'; - status: 'active' | 'inactive'; -} - -const USERS: UserSeed[] = [ - { - name: 'Admin User', - email: 'admin@example.com', - password: 'admin123', - role: 'system_admin', - status: 'active', - }, - { - name: 'Alice Tanaka', - email: 'alice@example.com', - password: 'password', - role: 'member', - status: 'active', - }, - { - name: 'Bob Sato', - email: 'bob@example.com', - password: 'password', - role: 'member', - status: 'active', - }, - { - name: 'Carol Yamada', - email: 'carol@example.com', - password: 'password', - role: 'member', - status: 'active', - }, - { - name: 'Dave Suzuki', - email: 'dave@example.com', - password: 'password', - role: 'member', - status: 'active', - }, - { - name: 'Eve Mori (inactive)', - email: 'eve@example.com', - password: 'password', - role: 'member', - status: 'inactive', - }, -]; - -// 1x1 透明PNG(ファイル共有デモ用) -const PNG_BYTES = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', - 'base64' -); - -function insert( - db: SqliteDatabase, - sql: string, - params: Record = {} -): number { - return Number(db.execute(sql, params).lastInsertRowid); -} +import { seedAll } from './seed/generators'; function resetStorage(dbPath: string, uploadsDir: string): void { for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { @@ -103,565 +27,6 @@ function resetStorage(dbPath: string, uploadsDir: string): void { fs.mkdirSync(uploadsDir, { recursive: true }); } -function seedUsers(db: SqliteDatabase): Map { - const ids = new Map(); - for (const u of USERS) { - const id = insert( - db, - `INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at) - VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`, - { - name: u.name, - email: u.email, - passwordHash: bcrypt.hashSync(u.password, BCRYPT_ROUNDS), - role: u.role, - status: u.status, - createdAt: now(), - updatedAt: now(), - } - ); - ids.set(u.email, id); - } - return ids; -} - -interface ProjectSeed { - name: string; - description: string; - status: 'active' | 'on_hold' | 'completed' | 'archived'; - owner: string; // email - members: { email: string; role: 'admin' | 'member' | 'guest' }[]; -} - -const PROJECTS: ProjectSeed[] = [ - { - name: 'Website Redesign', - description: 'コーポレートサイトの全面リニューアルプロジェクト', - status: 'active', - owner: 'alice@example.com', - members: [ - { email: 'bob@example.com', role: 'member' }, - { email: 'carol@example.com', role: 'member' }, - ], - }, - { - name: 'Mobile App v2', - description: 'iOS/Androidアプリの次期メジャーバージョン開発', - status: 'active', - owner: 'bob@example.com', - members: [ - { email: 'alice@example.com', role: 'member' }, - { email: 'dave@example.com', role: 'member' }, - ], - }, - { - name: 'Marketing Campaign Q4', - description: '第4四半期マーケティングキャンペーンの企画・実行', - status: 'on_hold', - owner: 'carol@example.com', - members: [ - { email: 'alice@example.com', role: 'member' }, - { email: 'bob@example.com', role: 'member' }, - { email: 'dave@example.com', role: 'guest' }, - ], - }, -]; - -function addMember( - db: SqliteDatabase, - projectId: number, - userId: number, - role: string -): void { - insert( - db, - `INSERT INTO project_members (project_id, user_id, role, joined_at) - VALUES (@projectId, @userId, @role, @joinedAt)`, - { projectId, userId, role, joinedAt: now() } - ); -} - -function seedBoard( - db: SqliteDatabase, - projectId: number, - authorIds: number[] -): void { - const threads = [ - { - title: 'デザイン方針について', - body: '# デザイン方針\n\n- シンプルさを重視\n- モバイルファースト\n\nご意見ください。', - category: 'decision', - pinned: 1, - important: 1, - }, - { - title: '週次進捗報告', - body: '今週の進捗を共有します。\n\n| タスク | 状態 |\n|---|---|\n| トップ画面 | 完了 |\n| About画面 | 進行中 |', - category: 'minutes', - pinned: 0, - important: 0, - }, - { - title: 'FAQ: ログインできない', - body: 'ログインできない場合のトラブルシューティングです。', - category: 'trouble', - pinned: 0, - important: 0, - }, - ]; - for (const t of threads) { - const author = authorIds[0]; - const threadId = insert( - db, - `INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at) - VALUES (@projectId, @title, @bodyMd, @authorId, @category, @isPinned, @isImportant, @createdAt, @updatedAt, NULL)`, - { - projectId, - title: t.title, - bodyMd: t.body, - authorId: author, - category: t.category, - isPinned: t.pinned, - isImportant: t.important, - createdAt: now(), - updatedAt: now(), - } - ); - // コメント - const commenter = authorIds[1] ?? author; - insert( - db, - `INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at) - VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`, - { - threadId, - authorId: commenter, - bodyMd: '確認しました。ありがとうございます!', - createdAt: now(), - updatedAt: now(), - } - ); - } -} - -function seedChat( - db: SqliteDatabase, - projectId: number, - authorIds: number[] -): void { - const messages = [ - { author: 0, body: 'おはようございます!今日もよろしくお願いします。' }, - { - author: 1, - body: 'おつですー。 @alice@example.com ちょっと確認したいことあります', - }, - { author: 0, body: '何でしょう?' }, - { author: 1, body: 'デザインのカラーパレット、これで確定で大丈夫ですか?' }, - { author: 0, body: '問題ないです!進めましょう :)' }, - ]; - for (const m of messages) { - insert( - db, - `INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at) - VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`, - { - projectId, - authorId: authorIds[m.author], - body: m.body, - createdAt: now(), - updatedAt: now(), - } - ); - } -} - -function seedTodos( - db: SqliteDatabase, - projectId: number, - memberIds: number[], - milestoneIds: number[] -): void { - const columns = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done']; - const colIds: number[] = []; - columns.forEach((name, idx) => { - colIds.push( - insert( - db, - `INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at) - VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`, - { projectId, name, orderIndex: idx, createdAt: now(), updatedAt: now() } - ) - ); - }); - - const items = [ - { - col: 0, - title: 'アイデア募集: ヘッダー案', - priority: 'low', - assignee: null, - due: null, - milestone: null, - completed: false, - }, - { - col: 1, - title: 'トップ画面の実装', - priority: 'high', - assignee: 0, - due: dayStr(2), - milestone: 0, - completed: false, - }, - { - col: 1, - title: 'About画面の実装', - priority: 'normal', - assignee: 1, - due: dayStr(5), - milestone: 0, - completed: false, - }, - { - col: 2, - title: '問い合わせフォーム', - priority: 'normal', - assignee: 0, - due: dayStr(7), - milestone: 1, - completed: false, - }, - { - col: 3, - title: 'デザインレビュー', - priority: 'high', - assignee: 1, - due: dayStr(1), - milestone: 1, - completed: false, - }, - { - col: 4, - title: '要件定義', - priority: 'normal', - assignee: 0, - due: dayStr(-3), - milestone: 0, - completed: true, - }, - { - col: 4, - title: 'ワイヤフレーム', - priority: 'normal', - assignee: 1, - due: dayStr(-1), - milestone: 0, - completed: true, - }, - ]; - items.forEach((it, idx) => { - const assigneeId = it.assignee === null ? null : memberIds[it.assignee]; - const milestoneId = - it.milestone === null ? null : milestoneIds[it.milestone]; - insert( - db, - `INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at) - VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, @completedAt, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`, - { - projectId, - columnId: colIds[it.col], - title: it.title, - description: 'デモ用タスクです。', - assigneeId, - creatorId: memberIds[0], - priority: it.priority, - dueDate: it.due, - completedAt: it.completed ? now() : null, - orderIndex: idx, - milestoneId, - createdAt: now(), - updatedAt: now(), - } - ); - }); -} - -function seedNotes( - db: SqliteDatabase, - projectId: number, - authorIds: number[] -): void { - const notes = [ - { - title: 'ミーティングメモ', - body: '# ミーティングメモ\n\n## 決定事項\n- カラーテーマ: 青\n- リリース: 来月\n\n## 宿題\n- [ ] デザイン作成\n- [ ] レビュー', - tags: 'meeting,notes', - pinned: 1, - }, - { - title: '技術メモ', - body: '## 技術スタック\n\n- Next.js 15\n- SQLite\n- Tailwind CSS\n\n```ts\nconst x = 1;\n```', - tags: 'tech', - pinned: 0, - }, - { - title: 'アイデア', - body: '面白い機能のアイデアをメモしておく場所。', - tags: null, - pinned: 0, - }, - ]; - for (const n of notes) { - insert( - db, - `INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at) - VALUES (@projectId, @title, @bodyMd, @tags, @isPinned, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`, - { - projectId, - title: n.title, - bodyMd: n.body, - tags: n.tags, - isPinned: n.pinned, - createdById: authorIds[0], - updatedById: authorIds[0], - createdAt: now(), - updatedAt: now(), - } - ); - } -} - -function seedFiles( - db: SqliteDatabase, - projectId: number, - uploaderId: number, - uploadsDir: string -): void { - const dir = path.join(uploadsDir, String(projectId)); - fs.mkdirSync(dir, { recursive: true }); - - // 画像ファイル - const imgName = `${crypto.randomUUID()}.png`; - const imgPath = path.join(dir, imgName); - fs.writeFileSync(imgPath, PNG_BYTES); - insert( - db, - `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) - VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, - { - projectId, - uploaderId, - filename: imgName, - originalName: 'sample-diagram.png', - mimeType: 'image/png', - size: PNG_BYTES.length, - path: imgPath, - createdAt: now(), - } - ); - - // テキストファイル - const txtName = `${crypto.randomUUID()}.txt`; - const txtPath = path.join(dir, txtName); - const txtContent = - 'これはデモ用テキストファイルです。\nシードスクリプトによって生成されました。'; - fs.writeFileSync(txtPath, txtContent, 'utf-8'); - insert( - db, - `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) - VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, - { - projectId, - uploaderId, - filename: txtName, - originalName: 'spec-notes.txt', - mimeType: 'text/plain', - size: Buffer.byteLength(txtContent), - path: txtPath, - createdAt: now(), - } - ); -} - -function seedMilestones(db: SqliteDatabase, projectId: number): number[] { - const ms = [ - { title: 'M1: デザイン完了', due: dayStr(7) }, - { title: 'M2: 実装完了', due: dayStr(21) }, - ]; - const ids: number[] = []; - for (const m of ms) { - ids.push( - insert( - db, - `INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at) - VALUES (@projectId, @title, @description, @dueDate, 'open', @createdAt, @updatedAt, NULL)`, - { - projectId, - title: m.title, - description: 'マイルストーンです。', - dueDate: m.due, - createdAt: now(), - updatedAt: now(), - } - ) - ); - } - return ids; -} - -function seedCalendar( - db: SqliteDatabase, - projectId: number, - creatorId: number -): void { - const events = [ - { - title: '定例ミーティング', - type: 'meeting', - start: daysFromNow(1), - end: daysFromNow(1), - }, - { - title: 'デザインレビュー', - type: 'reminder', - start: daysFromNow(3), - end: null, - }, - { title: 'リリース締切', type: 'deadline', start: dayStr(14), end: null }, - ]; - for (const e of events) { - insert( - db, - `INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at) - VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, NULL, NULL, NULL, @createdAt, @updatedAt, NULL)`, - { - projectId, - title: e.title, - description: 'カレンダーイベントです。', - type: e.type, - startAt: e.start, - endAt: e.end, - createdById: creatorId, - createdAt: now(), - updatedAt: now(), - } - ); - } -} - -function seedMeetings( - db: SqliteDatabase, - projectId: number, - creatorId: number, - memberIds: number[] -): void { - const meetingId = insert( - db, - `INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at) - VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, @minutesMd, @createdById, @createdAt, @updatedAt, NULL)`, - { - projectId, - title: '週次定例ミーティング', - description: '進捗確認と課題共有', - location: '会議室A / オンライン', - meetingUrl: 'https://meet.example.com/abc-defg-hij', - startAt: daysFromNow(2), - endAt: daysFromNow(2), - agendaMd: '# アジェンダ\n\n1. 進捗共有\n2. ブロッカー確認\n3. 次週の計画', - minutesMd: '# 議事録\n\n- トップ画面は完了\n- About画面は来週完了予定', - createdById: creatorId, - createdAt: now(), - updatedAt: now(), - } - ); - for (const uid of memberIds) { - insert( - db, - `INSERT INTO meeting_members (meeting_id, user_id, status) VALUES (@meetingId, @userId, 'invited')`, - { meetingId, userId: uid } - ); - } -} - -function seedNotifications( - db: SqliteDatabase, - projectId: number, - memberIds: number[] -): void { - const notifs = [ - { - user: 0, - type: 'mention', - title: 'メンションされました', - body: 'チャットでメンションされました', - }, - { - user: 1, - type: 'todo_assigned', - title: 'ToDoが割り当てられました', - body: 'トップ画面の実装', - }, - { - user: 0, - type: 'meeting_invited', - title: 'ミーティングに招待されました', - body: '週次定例ミーティング', - }, - ]; - for (const n of notifs) { - insert( - db, - `INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at) - VALUES (@userId, @projectId, @type, @title, @body, NULL, @createdAt)`, - { - userId: memberIds[n.user], - projectId, - type: n.type, - title: n.title, - body: n.body, - createdAt: now(), - } - ); - } -} - -function seedActivityLogs( - db: SqliteDatabase, - projectId: number, - actorIds: number[] -): void { - const logs = [ - { actor: 0, action: 'board_posted', targetType: 'thread', targetId: 1 }, - { actor: 1, action: 'comment_added', targetType: 'comment', targetId: 1 }, - { actor: 0, action: 'todo_created', targetType: 'todo', targetId: 1 }, - { actor: 1, action: 'todo_completed', targetType: 'todo', targetId: 6 }, - { actor: 0, action: 'file_uploaded', targetType: 'file', targetId: 1 }, - { actor: 0, action: 'meeting_created', targetType: 'meeting', targetId: 1 }, - { actor: 1, action: 'note_updated', targetType: 'note', targetId: 1 }, - { - actor: 0, - action: 'milestone_updated', - targetType: 'milestone', - targetId: 1, - }, - ]; - for (const l of logs) { - insert( - db, - `INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at) - VALUES (@projectId, @actorId, @action, @targetType, @targetId, NULL, @createdAt)`, - { - projectId, - actorId: actorIds[l.actor], - action: l.action, - targetType: l.targetType, - targetId: l.targetId, - createdAt: now(), - } - ); - } -} - function main(): void { const dbPath = process.env.SQLITE_PATH ?? './data/app.db'; const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads'; @@ -675,54 +40,18 @@ function main(): void { path.join(process.cwd(), 'lib', 'db', 'migrations') ).migrate(); - console.log('Seeding users...'); - const userIds = seedUsers(db); - - let projectCount = 0; - for (const p of PROJECTS) { - const ownerId = userIds.get(p.owner)!; - const projectId = insert( - db, - `INSERT INTO projects (name, description, status, owner_id, created_at, updated_at) - VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`, - { - name: p.name, - description: p.description, - status: p.status, - ownerId, - createdAt: now(), - updatedAt: now(), - } - ); - addMember(db, projectId, ownerId, 'admin'); - const memberIds = [ownerId]; - for (const m of p.members) { - const uid = userIds.get(m.email)!; - addMember(db, projectId, uid, m.role); - if (!memberIds.includes(uid)) memberIds.push(uid); - } - - const milestoneIds = seedMilestones(db, projectId); - seedBoard(db, projectId, memberIds); - seedChat(db, projectId, memberIds); - seedTodos(db, projectId, memberIds, milestoneIds); - seedNotes(db, projectId, memberIds); - seedFiles(db, projectId, ownerId, uploadsDir); - seedCalendar(db, projectId, ownerId); - seedMeetings(db, projectId, ownerId, memberIds); - seedNotifications(db, projectId, memberIds); - seedActivityLogs(db, projectId, memberIds); - projectCount++; - } + console.log('Seeding demo data (~5x volume)...'); + const { userCount, projectCount } = seedAll(db, uploadsDir); db.close(); console.log('\n=== Seed complete ==='); - console.log(`Users: ${USERS.length} | Projects: ${projectCount}`); + console.log(`Users: ${userCount} | Projects: ${projectCount}`); console.log('\nLogin credentials:'); console.log(' Admin: admin@example.com / admin123 (system_admin)'); console.log(' Users: alice / bob / carol / dave @example.com / password'); console.log(' Inactive: eve@example.com / password (ログイン不可)'); + console.log(' Others: .@example.com / password'); console.log('\nStart the app with: npm run dev -> http://localhost:3000'); } diff --git a/scripts/seed/content.ts b/scripts/seed/content.ts new file mode 100644 index 0000000..8595f65 --- /dev/null +++ b/scripts/seed/content.ts @@ -0,0 +1,233 @@ +/** + * プロジェクト単位のコンテンツ生成器(掲示板/チャット/ToDo/メモ/ファイル)。 + * いずれもFK順を担保し、挿入した実IDを呼び出し側へ返す。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { chance, pick, randInt, seededUuid, type Rng } from './rng'; +import { + BOARD_CATEGORIES, + BOARD_TITLES, + CHAT_PHRASES, + NOTE_TAGS, + NOTE_TITLES, + TODO_PRIORITIES, + TODO_TITLES, + markdownBody, + sentence, +} from './pools'; +import { PNG_BYTES, dayStr, insert, now } from './helpers'; + +export function seedBoard( + db: SqliteDatabase, + rng: Rng, + projectId: number, + memberIds: number[] +): { threadIds: number[]; commentIds: number[] } { + const threadIds: number[] = []; + const commentIds: number[] = []; + const count = randInt(rng, 3, 5); + for (let i = 0; i < count; i++) { + const title = pick(rng, BOARD_TITLES); + const threadId = insert( + db, + `INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @bodyMd, @authorId, @category, @isPinned, @isImportant, @createdAt, @updatedAt, NULL)`, + { + projectId, + title, + bodyMd: markdownBody(rng, title), + authorId: pick(rng, memberIds), + category: pick(rng, BOARD_CATEGORIES), + isPinned: chance(rng, 0.15) ? 1 : 0, + isImportant: chance(rng, 0.2) ? 1 : 0, + createdAt: now(), + updatedAt: now(), + } + ); + threadIds.push(threadId); + const commentCount = randInt(rng, 0, 2); + for (let c = 0; c < commentCount; c++) { + const cid = insert( + db, + `INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at) + VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`, + { + threadId, + authorId: pick(rng, memberIds), + bodyMd: pick(rng, CHAT_PHRASES), + createdAt: now(), + updatedAt: now(), + } + ); + commentIds.push(cid); + } + } + return { threadIds, commentIds }; +} + +export function seedChat( + db: SqliteDatabase, + rng: Rng, + projectId: number, + memberIds: number[] +): number[] { + const ids: number[] = []; + const count = randInt(rng, 5, 8); + for (let i = 0; i < count; i++) { + const id = insert( + db, + `INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at) + VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`, + { + projectId, + authorId: pick(rng, memberIds), + body: pick(rng, CHAT_PHRASES), + createdAt: now(), + updatedAt: now(), + } + ); + ids.push(id); + } + return ids; +} + +export function seedTodos( + db: SqliteDatabase, + rng: Rng, + projectId: number, + memberIds: number[], + milestoneIds: number[] +): number[] { + const columns = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done']; + const colIds = columns.map((name, idx) => + insert( + db, + `INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at) + VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`, + { projectId, name, orderIndex: idx, createdAt: now(), updatedAt: now() } + ) + ); + const ids: number[] = []; + const count = randInt(rng, 7, 10); + for (let i = 0; i < count; i++) { + const colIdx = randInt(rng, 0, columns.length - 1); + const completed = colIdx === columns.length - 1; + const id = insert( + db, + `INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, @completedAt, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`, + { + projectId, + columnId: colIds[colIdx]!, + title: pick(rng, TODO_TITLES), + description: 'デモ用タスクです。', + assigneeId: chance(rng, 0.8) ? pick(rng, memberIds) : null, + creatorId: memberIds[0]!, + priority: pick(rng, TODO_PRIORITIES), + dueDate: chance(rng, 0.7) ? dayStr(randInt(rng, -10, 30)) : null, + completedAt: completed ? now() : null, + orderIndex: i, + milestoneId: + chance(rng, 0.6) && milestoneIds.length > 0 + ? pick(rng, milestoneIds) + : null, + createdAt: now(), + updatedAt: now(), + } + ); + ids.push(id); + } + return ids; +} + +export function seedNotes( + db: SqliteDatabase, + rng: Rng, + projectId: number, + memberIds: number[] +): number[] { + const ids: number[] = []; + const count = randInt(rng, 3, 5); + for (let i = 0; i < count; i++) { + const authorId = pick(rng, memberIds); + const title = pick(rng, NOTE_TITLES); + const id = insert( + db, + `INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @bodyMd, @tags, @isPinned, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`, + { + projectId, + title, + bodyMd: markdownBody(rng, title), + tags: pick(rng, NOTE_TAGS), + isPinned: chance(rng, 0.2) ? 1 : 0, + createdById: authorId, + updatedById: authorId, + createdAt: now(), + updatedAt: now(), + } + ); + ids.push(id); + } + return ids; +} + +export function seedFiles( + db: SqliteDatabase, + rng: Rng, + projectId: number, + uploaderId: number, + uploadsDir: string +): number[] { + const dir = path.join(uploadsDir, String(projectId)); + fs.mkdirSync(dir, { recursive: true }); + const ids: number[] = []; + const count = randInt(rng, 2, 4); + for (let i = 0; i < count; i++) { + if (chance(rng, 0.5)) { + const filename = `${seededUuid(rng)}.png`; + const filePath = path.join(dir, filename); + fs.writeFileSync(filePath, PNG_BYTES); + const id = insert( + db, + `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) + VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, + { + projectId, + uploaderId, + filename, + originalName: `diagram-${i + 1}.png`, + mimeType: 'image/png', + size: PNG_BYTES.length, + path: filePath, + createdAt: now(), + } + ); + ids.push(id); + } else { + const filename = `${seededUuid(rng)}.txt`; + const filePath = path.join(dir, filename); + const content = `デモ用テキストファイル ${i + 1}。\n${sentence(rng)}`; + fs.writeFileSync(filePath, content, 'utf-8'); + const id = insert( + db, + `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) + VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, + { + projectId, + uploaderId, + filename, + originalName: `notes-${i + 1}.txt`, + mimeType: 'text/plain', + size: Buffer.byteLength(content), + path: filePath, + createdAt: now(), + } + ); + ids.push(id); + } + } + return ids; +} diff --git a/scripts/seed/generators.ts b/scripts/seed/generators.ts new file mode 100644 index 0000000..b9aa6ff --- /dev/null +++ b/scripts/seed/generators.ts @@ -0,0 +1,203 @@ +/** + * シードデータ生成のオーケストレータ。 + * ユーザーとプロジェクトを生成し、各プロジェクト単位の生成器(content.ts)をFK順に呼び出す。 + * 生成内容は固定シード(mulberry32)で決定論的。 + */ +import bcrypt from 'bcrypt'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { createRng, pick, pickN, randInt, chance, type Rng } from './rng'; +import { + FAMILY_NAMES, + GIVEN_NAMES, + PROJECT_DESCRIPTIONS, + PROJECT_NAMES, +} from './pools'; +import { + CANONICAL_PROJECTS, + CANONICAL_USERS, + SEED, + TARGET_PROJECTS, + TARGET_USERS, + type UserSeed, + addMember, + bcryptSalt, + insert, + now, + type ProjectSpec, +} from './helpers'; +import { + seedBoard, + seedChat, + seedFiles, + seedNotes, + seedTodos, +} from './content'; +import { seedCalendar, seedMeetings, seedMilestones } from './schedule'; +import { seedActivityLogs, seedNotifications } from './logs'; + +function buildUserSeeds(rng: Rng): UserSeed[] { + const seeds = [...CANONICAL_USERS]; + const usedEmails = new Set(seeds.map((u) => u.email)); + const usedNames = new Set(seeds.map((u) => u.name)); + while (seeds.length < TARGET_USERS) { + const given = pick(rng, GIVEN_NAMES); + const family = pick(rng, FAMILY_NAMES); + const name = `${given} ${family}`; + if (usedNames.has(name)) continue; + let email = `${given.toLowerCase()}.${family.toLowerCase()}@example.com`; + let suffix = 1; + while (usedEmails.has(email)) { + email = `${given.toLowerCase()}.${family.toLowerCase()}${suffix}@example.com`; + suffix++; + } + usedEmails.add(email); + usedNames.add(name); + const status = chance(rng, 0.1) ? 'inactive' : 'active'; + seeds.push({ name, email, password: 'password', role: 'member', status }); + } + return seeds; +} + +function seedUsers( + db: SqliteDatabase, + rng: Rng +): { emailToId: Map; activeMemberIds: number[] } { + const seeds = buildUserSeeds(rng); + const emailToId = new Map(); + const activeMemberIds: number[] = []; + for (const u of seeds) { + const id = insert( + db, + `INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at) + VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`, + { + name: u.name, + email: u.email, + passwordHash: bcrypt.hashSync(u.password, bcryptSalt(rng)), + role: u.role, + status: u.status, + createdAt: now(), + updatedAt: now(), + } + ); + emailToId.set(u.email, id); + if (u.role === 'member' && u.status === 'active') activeMemberIds.push(id); + } + return { emailToId, activeMemberIds }; +} + +function buildProjectSpecs(rng: Rng): ProjectSpec[] { + const specs: ProjectSpec[] = CANONICAL_PROJECTS.map((p) => ({ + name: p.name, + description: p.description, + status: p.status, + ownerEmail: p.ownerEmail, + memberEmails: p.memberEmails, + })); + const usedNames = new Set(specs.map((s) => s.name)); + const names = pickN(rng, PROJECT_NAMES, PROJECT_NAMES.length).filter( + (n) => !usedNames.has(n) + ); + const descs = pickN(rng, PROJECT_DESCRIPTIONS, PROJECT_DESCRIPTIONS.length); + let ni = 0; + let di = 0; + while (specs.length < TARGET_PROJECTS) { + const name = names[ni] ?? `Internal Project ${specs.length + 1}`; + ni++; + const description = descs[di % descs.length] ?? 'デモ用プロジェクトです。'; + di++; + const r = rng(); + const status: ProjectSpec['status'] = + r < 0.55 + ? 'active' + : r < 0.75 + ? 'on_hold' + : r < 0.9 + ? 'completed' + : 'archived'; + specs.push({ + name, + description, + status, + ownerEmail: null, + memberEmails: [], + }); + } + return specs; +} + +export function seedAll( + db: SqliteDatabase, + uploadsDir: string +): { userCount: number; projectCount: number } { + const rng = createRng(SEED); + const { emailToId, activeMemberIds } = seedUsers(db, rng); + + const specs = buildProjectSpecs(rng); + for (const spec of specs) { + const ownerId = spec.ownerEmail + ? (emailToId.get(spec.ownerEmail) ?? activeMemberIds[0]!) + : pick(rng, activeMemberIds); + const projectId = insert( + db, + `INSERT INTO projects (name, description, status, owner_id, created_at, updated_at) + VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`, + { + name: spec.name, + description: spec.description, + status: spec.status, + ownerId, + createdAt: now(), + updatedAt: now(), + } + ); + addMember(db, projectId, ownerId, 'admin'); + const memberIds = [ownerId]; + const extras = + spec.memberEmails.length > 0 + ? spec.memberEmails.map((m) => ({ + id: emailToId.get(m.email)!, + role: m.role, + })) + : pickN( + rng, + activeMemberIds.filter((id) => id !== ownerId), + randInt(rng, 2, 4) + ).map((id) => ({ id, role: 'member' as const })); + for (const m of extras) { + if (!memberIds.includes(m.id)) { + addMember(db, projectId, m.id, m.role); + memberIds.push(m.id); + } + } + + const milestoneIds = seedMilestones(db, rng, projectId); + const { threadIds, commentIds } = seedBoard(db, rng, projectId, memberIds); + seedChat(db, rng, projectId, memberIds); + const todoIds = seedTodos(db, rng, projectId, memberIds, milestoneIds); + const noteIds = seedNotes(db, rng, projectId, memberIds); + const fileIds = seedFiles(db, rng, projectId, ownerId, uploadsDir); + const meetingIds = seedMeetings(db, rng, projectId, ownerId, memberIds); + seedCalendar( + db, + rng, + projectId, + ownerId, + todoIds, + milestoneIds, + meetingIds + ); + seedNotifications(db, rng, projectId, memberIds); + seedActivityLogs(db, rng, projectId, memberIds, { + threadIds, + commentIds, + todoIds, + fileIds, + meetingIds, + noteIds, + milestoneIds, + }); + } + + return { userCount: emailToId.size, projectCount: specs.length }; +} diff --git a/scripts/seed/helpers.ts b/scripts/seed/helpers.ts new file mode 100644 index 0000000..5781bef --- /dev/null +++ b/scripts/seed/helpers.ts @@ -0,0 +1,170 @@ +/** + * シード生成器間で共有する定数・時刻ヘルパ・挿入ヘルパ・カノニカルデータ。 + */ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { ProjectMemberRole } from '@/lib/types'; +import { type Rng } from './rng'; + +export const BCRYPT_ROUNDS = 10; +export const SEED = 0xc0ffee; +export const TARGET_USERS = 30; +export const TARGET_PROJECTS = 15; + +// 実行日(UTC 0時)を基準にすることで、同日内の再実行で同一データになる。 +const BASE_DATE = (() => { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + return d; +})(); +const BASE_TIME = BASE_DATE.toISOString(); +export const now = (): string => BASE_TIME; +export const daysFromNow = (d: number): string => { + const dt = new Date(BASE_DATE); + dt.setUTCDate(dt.getUTCDate() + d); + return dt.toISOString(); +}; +export const dayStr = (d: number): string => daysFromNow(d).slice(0, 10); + +export const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' +); + +const BCRYPT_SALT_ALPHABET = + './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +export function bcryptSalt(rng: Rng): string { + let salt = ''; + for (let i = 0; i < 22; i++) { + salt += + BCRYPT_SALT_ALPHABET[Math.floor(rng() * BCRYPT_SALT_ALPHABET.length)]; + } + return `$2b$${BCRYPT_ROUNDS}$${salt}`; +} + +export interface UserSeed { + name: string; + email: string; + password: string; + role: 'system_admin' | 'member'; + status: 'active' | 'inactive'; +} + +export interface ProjectSpec { + name: string; + description: string; + status: 'active' | 'on_hold' | 'completed' | 'archived'; + ownerEmail: string | null; + memberEmails: { email: string; role: 'admin' | 'member' | 'guest' }[]; +} + +export interface ActivityContext { + threadIds: number[]; + commentIds: number[]; + todoIds: number[]; + fileIds: number[]; + meetingIds: number[]; + noteIds: number[]; + milestoneIds: number[]; +} + +export const CANONICAL_USERS: UserSeed[] = [ + { + name: 'Admin User', + email: 'admin@example.com', + password: 'admin123', + role: 'system_admin', + status: 'active', + }, + { + name: 'Alice Tanaka', + email: 'alice@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Bob Sato', + email: 'bob@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Carol Yamada', + email: 'carol@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Dave Suzuki', + email: 'dave@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Eve Mori (inactive)', + email: 'eve@example.com', + password: 'password', + role: 'member', + status: 'inactive', + }, +]; + +export const CANONICAL_PROJECTS: ProjectSpec[] = [ + { + name: 'Website Redesign', + description: 'コーポレートサイトの全面リニューアルプロジェクト', + status: 'active', + ownerEmail: 'alice@example.com', + memberEmails: [ + { email: 'bob@example.com', role: 'member' }, + { email: 'carol@example.com', role: 'member' }, + ], + }, + { + name: 'Mobile App v2', + description: 'iOS/Androidアプリの次期メジャーバージョン開発', + status: 'active', + ownerEmail: 'bob@example.com', + memberEmails: [ + { email: 'alice@example.com', role: 'member' }, + { email: 'dave@example.com', role: 'member' }, + ], + }, + { + name: 'Marketing Campaign Q4', + description: '第4四半期マーケティングキャンペーンの企画・実行', + status: 'on_hold', + ownerEmail: 'carol@example.com', + memberEmails: [ + { email: 'alice@example.com', role: 'member' }, + { email: 'bob@example.com', role: 'member' }, + { email: 'dave@example.com', role: 'guest' }, + ], + }, +]; + +export function insert( + db: SqliteDatabase, + sql: string, + params: Record = {} +): number { + return Number(db.execute(sql, params).lastInsertRowid); +} + +export function addMember( + db: SqliteDatabase, + projectId: number, + userId: number, + role: ProjectMemberRole +): void { + insert( + db, + `INSERT INTO project_members (project_id, user_id, role, joined_at) + VALUES (@projectId, @userId, @role, @joinedAt)`, + { projectId, userId, role, joinedAt: now() } + ); +} diff --git a/scripts/seed/logs.ts b/scripts/seed/logs.ts new file mode 100644 index 0000000..7f62b5f --- /dev/null +++ b/scripts/seed/logs.ts @@ -0,0 +1,70 @@ +/** + * 通知とアクティビティログの生成器。アクティビティログは実IDを参照する。 + */ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { chance, pick, randInt, type Rng } from './rng'; +import { ACTIVITY_ACTIONS, NOTIF_TEMPLATES } from './pools'; +import { type ActivityContext, insert, now } from './helpers'; + +export function seedNotifications( + db: SqliteDatabase, + rng: Rng, + projectId: number, + memberIds: number[] +): void { + const count = randInt(rng, 3, 5); + for (let i = 0; i < count; i++) { + const t = pick(rng, NOTIF_TEMPLATES); + insert( + db, + `INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at) + VALUES (@userId, @projectId, @type, @title, @body, @readAt, @createdAt)`, + { + userId: pick(rng, memberIds), + projectId, + type: t.type, + title: t.title, + body: t.body, + readAt: chance(rng, 0.3) ? now() : null, + createdAt: now(), + } + ); + } +} + +export function seedActivityLogs( + db: SqliteDatabase, + rng: Rng, + projectId: number, + memberIds: number[], + ctx: ActivityContext +): void { + const targetsByType: Record = { + thread: ctx.threadIds, + comment: ctx.commentIds, + todo: ctx.todoIds, + file: ctx.fileIds, + meeting: ctx.meetingIds, + note: ctx.noteIds, + milestone: ctx.milestoneIds, + }; + const count = randInt(rng, 8, 12); + for (let i = 0; i < count; i++) { + const a = pick(rng, ACTIVITY_ACTIONS); + const pool = targetsByType[a.targetType] ?? []; + if (pool.length === 0) continue; + insert( + db, + `INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at) + VALUES (@projectId, @actorId, @action, @targetType, @targetId, NULL, @createdAt)`, + { + projectId, + actorId: pick(rng, memberIds), + action: a.action, + targetType: a.targetType, + targetId: pick(rng, pool), + createdAt: now(), + } + ); + } +} diff --git a/scripts/seed/pools.ts b/scripts/seed/pools.ts new file mode 100644 index 0000000..6e86b68 --- /dev/null +++ b/scripts/seed/pools.ts @@ -0,0 +1,374 @@ +/** + * シードデータ用のコンテンツプールとテキスト生成ヘルパ。 + * lib/types/index.ts の列挙型に合わせた値のみを使用する。 + */ +import type { + BoardCategory, + CalendarEventType, + NotificationType, + TodoPriority, +} from '@/lib/types'; +import { type Rng, chance, pick, pickN, randInt } from './rng'; + +export const GIVEN_NAMES = [ + 'Frank', + 'Grace', + 'Henry', + 'Ivy', + 'Jack', + 'Karen', + 'Leo', + 'Mia', + 'Noah', + 'Olivia', + 'Peter', + 'Quinn', + 'Rachel', + 'Sam', + 'Tina', + 'Uma', + 'Victor', + 'Wendy', + 'Xander', + 'Yuki', + 'Zoe', + 'Amber', + 'Brian', + 'Chloe', + 'Diego', + 'Emma', + 'Finn', + 'Gina', + 'Hiro', + 'Iris', + 'Jonas', + 'Kira', +]; + +export const FAMILY_NAMES = [ + 'Yamamoto', + 'Watanabe', + 'Ito', + 'Nakamura', + 'Kobayashi', + 'Saito', + 'Takahashi', + 'Kato', + 'Yoshida', + 'Sasaki', + 'Matsumoto', + 'Inoue', + 'Kimura', + 'Hayashi', + 'Shimizu', + 'Yamazaki', + 'Mori', + 'Abe', + 'Ikeda', + 'Hashimoto', + 'Yamaguchi', + 'Kondo', + 'Ishikawa', + 'Ogawa', +]; + +export const PROJECT_NAMES = [ + 'Website Redesign', + 'Mobile App v2', + 'Marketing Campaign Q4', + 'Data Warehouse Migration', + 'Security Audit 2026', + 'HR Portal Revamp', + 'Customer Support Bot', + 'API Gateway Upgrade', + 'Design System 2.0', + 'Sales CRM Integration', + 'Cloud Cost Optimization', + 'Accessibility Audit', + 'Onboarding Flow Redesign', + 'Analytics Dashboard', + 'DevOps Automation', + 'Internal Wiki Migration', + 'Payment Gateway v3', + 'QA Automation Suite', +]; + +export const PROJECT_DESCRIPTIONS = [ + 'コーポレートサイトの全面リニューアルプロジェクト', + 'iOS/Androidアプリの次期メジャーバージョン開発', + '第4四半期マーケティングキャンペーンの企画・実行', + 'レガシーDWHからモダンデータプラットフォームへの移行', + '全社システムのセキュリティ監査と是正', + '人事ポータルのUX改善と再構築', + 'カスタマーサポート向けチャットボットの開発', + 'APIゲートウェイの刷新とレートリミット強化', + '組織横断デザインシステムの次世代化', + '営業CRMと社内DBの双方向連携', + 'クラウド利用コストの可視化と削減', + '製品アクセシビリティの監査と改善', + '新規ユーザーオンボーディングフローの再設計', + '経営ダッシュボードの構築とBI連携', + 'デプロイパイプラインの自動化とCI/CD強化', + '社内Wikiの新プラットフォームへの移行', + '決済ゲートウェイの次期バージョン開発', + '回帰テストの自動化とカバレッジ向上', +]; + +export const TODO_TITLES = [ + '要件定義', + 'ワイヤフレーム作成', + 'デザインモックアップ', + 'フロントエンド実装', + 'バックエンドAPI実装', + 'DBスキーマ設計', + 'ユニットテスト追加', + 'E2Eテスト作成', + 'パフォーマンス計測', + 'セキュリティレビュー', + 'アクセシビリティ確認', + 'ドキュメント整備', + 'デプロイ手順策定', + 'ロールバック確認', + '負荷テスト', + 'ログ監視設定', + 'エラーハンドリング整理', + 'i18n対応', + 'ダッシュボード実装', + '検索機能実装', + '認証フロー見直し', + 'キャッシュ導入', + 'マニュアル作成', + 'レビュー対応', + 'リファクタリング', + '依存ライブラリ更新', + '本番環境検証', + '顧客デモ準備', + 'マイルストーン調整', + 'バグトリアージ', + 'データ移行スクリプト', + 'バッチジョブ実装', + 'Webhook連携', + 'レポート出力', + '権限設定見直し', +]; + +export const TODO_PRIORITIES: readonly TodoPriority[] = [ + 'low', + 'normal', + 'high', +]; + +export const NOTE_TITLES = [ + 'ミーティングメモ', + '技術メモ', + 'アイデア', + '議事録', + '振り返り', + '調査メモ', + '設計メモ', + 'QAメモ', + 'リリースノート案', + 'トラブル対応記録', + 'ブレスト', + '意思決定ログ', + 'タスク洗い出し', + 'インシデント報告', + '週報', +]; + +export const NOTE_TAGS = [ + 'meeting', + 'tech', + 'design', + 'qa', + 'release', + 'idea', + 'incident', + 'weekly', + null, +]; + +export const CHAT_PHRASES = [ + 'おはようございます!', + 'お疲れ様です。', + '進捗どうですか?', + '確認お願いします。', + 'ちょっと相談があります。', + '了解しました。', + 'ありがとうございます!', + '後で見ます。', + 'LGTMです。', + 'ブロッカー発生しました。', + '修正しました。レビューお願いします。', + 'ミーティングの時間変更できますか?', + '資料を共有します。', + 'いいですね!進めましょう。', + '懸念点をまとめました。', + 'デプロイ完了しました。', + 'テスト通りました。', + 'バグを見つけました。', + '仕様を再確認しましょう。', + '承知しました。', +]; + +export const BOARD_TITLES = [ + 'デザイン方針について', + '週次進捗報告', + 'FAQ: ログインできない', + 'リリース計画の共有', + 'アーキテクチャレビュー', + 'スプリント振り返り', + '新メンバーへのお知らせ', + 'トラブルシューティング', + '意思決定記録', + 'テスト戦略', + 'パフォーマンス改善案', + 'セキュリティアップデート', + '顧客フィードバックまとめ', + 'ドキュメント整理', + '次フェーズの構想', +]; + +export const BOARD_CATEGORIES: readonly BoardCategory[] = [ + 'notice', + 'spec', + 'minutes', + 'question', + 'decision', + 'trouble', + 'memo', +]; + +export const CALENDAR_TITLES = [ + '定例ミーティング', + 'デザインレビュー', + 'リリース締切', + 'スプリント計画', + '振り返り', + '1on1', + '全社朝会', + '顧客デモ', + '技術共有会', + '勉強会', + '保守ウィンドウ', + 'デプロイ', + 'テスト実施日', + 'キックオフ', + '回顧展示', +]; + +export const CALENDAR_TYPES: readonly CalendarEventType[] = [ + 'meeting', + 'deadline', + 'milestone', + 'todo', + 'reminder', + 'custom', +]; + +export const MEETING_TITLES = [ + '週次定例ミーティング', + '設計レビュー会', + 'スプリント計画会', + 'リリース判定会', + '障害対応ブリーフィング', +]; + +export const NOTIF_TEMPLATES: { + type: NotificationType; + title: string; + body: string; +}[] = [ + { + type: 'mention', + title: 'メンションされました', + body: 'チャットでメンションされました', + }, + { + type: 'todo_assigned', + title: 'ToDoが割り当てられました', + body: '新しいタスクが担当になりました', + }, + { + type: 'todo_due_soon', + title: '期限が近づいています', + body: 'ToDoの期限が迫っています', + }, + { + type: 'meeting_invited', + title: 'ミーティングに招待されました', + body: 'カレンダーを確認してください', + }, + { + type: 'board_commented', + title: '掲示板に返信がありました', + body: 'あなたのスレッドにコメントがつきました', + }, + { + type: 'project_added', + title: 'プロジェクトに追加されました', + body: '新しいプロジェクトのメンバーになりました', + }, + { + type: 'file_shared', + title: 'ファイルが共有されました', + body: '新しいファイルがアップロードされました', + }, + { + type: 'note_updated', + title: 'メモが更新されました', + body: '担当メモが更新されました', + }, +]; + +export const ACTIVITY_ACTIONS: { action: string; targetType: string }[] = [ + { action: 'board_posted', targetType: 'thread' }, + { action: 'comment_added', targetType: 'comment' }, + { action: 'todo_created', targetType: 'todo' }, + { action: 'todo_updated', targetType: 'todo' }, + { action: 'todo_completed', targetType: 'todo' }, + { action: 'file_uploaded', targetType: 'file' }, + { action: 'note_created', targetType: 'note' }, + { action: 'note_updated', targetType: 'note' }, + { action: 'meeting_created', targetType: 'meeting' }, + { action: 'milestone_updated', targetType: 'milestone' }, +]; + +const SENTENCES = [ + '今週は計画通りに進捗した。', + 'いくつかのブロッカーを解消した。', + '次フェーズのスコープを確定した。', + 'レビューで指摘事項を整理した。', + 'パフォーマンス測定を実施した。', + 'ドキュメントを最新化した。', + 'テストカバレッジを改善した。', + '依存ライブラリを更新した。', + '顧客からのフィードバックを反映した。', + 'インフラ構成を見直した。', +]; + +const SECTIONS = ['概要', '背景', '決定事項', '宿題', '次のステップ']; + +export function sentence(rng: Rng): string { + return pick(rng, SENTENCES); +} + +export function paragraph(rng: Rng, n: number): string { + return pickN(rng, SENTENCES, n).join(' '); +} + +export function markdownBody(rng: Rng, heading: string): string { + const lines: string[] = [`# ${heading}`, '']; + const sectionCount = randInt(rng, 2, 4); + const sections = pickN(rng, SECTIONS, sectionCount); + for (const s of sections) { + lines.push(`## ${s}`, ''); + lines.push(paragraph(rng, randInt(rng, 2, 3))); + if (chance(rng, 0.4)) { + lines.push(''); + lines.push('- 項目A', '- 項目B', '- 項目C'); + } + lines.push(''); + } + return lines.join('\n'); +} diff --git a/scripts/seed/rng.ts b/scripts/seed/rng.ts new file mode 100644 index 0000000..34fe86d --- /dev/null +++ b/scripts/seed/rng.ts @@ -0,0 +1,50 @@ +/** + * シードデータ生成用の決定論的乱数ユーティリティ。 + * mulberry32 を用い、固定シードで再現可能なデータを生成する。 + */ + +export type Rng = () => number; + +export function createRng(seed: number): Rng { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function pick(rng: Rng, arr: readonly T[]): T { + return arr[Math.floor(rng() * arr.length)]!; +} + +export function pickN(rng: Rng, arr: readonly T[], n: number): T[] { + return shuffle(rng, arr).slice(0, Math.min(n, arr.length)); +} + +export function randInt(rng: Rng, min: number, max: number): number { + return Math.floor(rng() * (max - min + 1)) + min; +} + +export function shuffle(rng: Rng, arr: readonly T[]): T[] { + const out = [...arr]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [out[i], out[j]] = [out[j]!, out[i]!]; + } + return out; +} + +export function chance(rng: Rng, p: number): boolean { + return rng() < p; +} + +export function seededUuid(rng: Rng): string { + const hex = () => + Math.floor(rng() * 0x10000) + .toString(16) + .padStart(4, '0'); + return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`; +} diff --git a/scripts/seed/schedule.ts b/scripts/seed/schedule.ts new file mode 100644 index 0000000..4c1c120 --- /dev/null +++ b/scripts/seed/schedule.ts @@ -0,0 +1,138 @@ +/** + * スケジュール系エンティティの生成器(マイルストーン/ミーティング/カレンダー)。 + */ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { MeetingMemberStatus, MilestoneStatus } from '@/lib/types'; +import { chance, pick, pickN, randInt, seededUuid, type Rng } from './rng'; +import { CALENDAR_TITLES, CALENDAR_TYPES, MEETING_TITLES } from './pools'; +import { dayStr, daysFromNow, insert, now } from './helpers'; + +const MILESTONE_STATUS_OPEN: MilestoneStatus = 'open'; +const MEETING_MEMBER_INVITED: MeetingMemberStatus = 'invited'; + +export function seedMilestones( + db: SqliteDatabase, + rng: Rng, + projectId: number +): number[] { + const ids: number[] = []; + const phases = ['計画', '設計', '実装', 'テスト', 'リリース', '振り返り']; + const count = randInt(rng, 2, 4); + for (let i = 0; i < count; i++) { + const id = insert( + db, + `INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @dueDate, @status, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: `M${i + 1}: ${phases[i % phases.length]}完了`, + description: 'マイルストーンです。', + dueDate: dayStr(randInt(rng, 3, 60)), + status: MILESTONE_STATUS_OPEN, + createdAt: now(), + updatedAt: now(), + } + ); + ids.push(id); + } + return ids; +} + +export function seedMeetings( + db: SqliteDatabase, + rng: Rng, + projectId: number, + creatorId: number, + memberIds: number[] +): number[] { + const ids: number[] = []; + const count = randInt(rng, 1, 2); + for (let i = 0; i < count; i++) { + const offset = randInt(rng, -5, 30); + const id = insert( + db, + `INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, @minutesMd, @createdById, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: pick(rng, MEETING_TITLES), + description: '進捗確認と課題共有', + location: pick(rng, [ + '会議室A', + '会議室B', + 'オンライン', + '会議室A / オンライン', + ]), + meetingUrl: chance(rng, 0.7) + ? `https://meet.example.com/${seededUuid(rng).slice(0, 8)}` + : null, + startAt: daysFromNow(offset), + endAt: daysFromNow(offset), + agendaMd: + '# アジェンダ\n\n1. 進捗共有\n2. ブロッカー確認\n3. 次週の計画', + minutesMd: chance(rng, 0.6) + ? '# 議事録\n\n- 進捗は順調\n- 課題を共有\n- 次週も継続' + : null, + createdById: creatorId, + createdAt: now(), + updatedAt: now(), + } + ); + const attendeeCount = randInt(rng, 2, Math.max(2, memberIds.length)); + for (const uid of pickN(rng, memberIds, attendeeCount)) { + insert( + db, + `INSERT INTO meeting_members (meeting_id, user_id, status) + VALUES (@meetingId, @userId, @status)`, + { meetingId: id, userId: uid, status: MEETING_MEMBER_INVITED } + ); + } + ids.push(id); + } + return ids; +} + +export function seedCalendar( + db: SqliteDatabase, + rng: Rng, + projectId: number, + creatorId: number, + todoIds: number[], + milestoneIds: number[], + meetingIds: number[] +): number[] { + const ids: number[] = []; + const count = randInt(rng, 3, 5); + for (let i = 0; i < count; i++) { + const type = pick(rng, CALENDAR_TYPES); + const offset = randInt(rng, -7, 45); + const id = insert( + db, + `INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, @relatedTodoId, @relatedMilestoneId, @relatedMeetingId, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: pick(rng, CALENDAR_TITLES), + description: 'カレンダーイベントです。', + type, + startAt: type === 'deadline' ? dayStr(offset) : daysFromNow(offset), + endAt: chance(rng, 0.4) ? daysFromNow(offset) : null, + createdById: creatorId, + relatedTodoId: + chance(rng, 0.2) && todoIds.length > 0 ? pick(rng, todoIds) : null, + relatedMilestoneId: + chance(rng, 0.15) && milestoneIds.length > 0 + ? pick(rng, milestoneIds) + : null, + relatedMeetingId: + chance(rng, 0.15) && meetingIds.length > 0 + ? pick(rng, meetingIds) + : null, + createdAt: now(), + updatedAt: now(), + } + ); + ids.push(id); + } + return ids; +}