feat(seed): ~5x richer demo data via deterministic generators
Rewrite scripts/seed-demo.ts as a thin orchestrator over a new scripts/seed/ module (rng, pools, helpers, content, schedule, logs, generators). Scales demo data from 6 users/3 projects to ~30 users/15 projects with ~5-8x per-table content (~6.8x total rows) via a seeded mulberry32 PRNG for full reproducibility (incl. bcrypt salts and file/URL identifiers). FK-safe ordering, real captured IDs for activity logs, enum pools typed against lib/types. Preserves canonical login credentials and projects.
This commit is contained in:
@ -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<string, unknown> = {}
|
||||
): 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<string, number> {
|
||||
const ids = new Map<string, number>();
|
||||
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: <firstname>.<lastname>@example.com / password');
|
||||
console.log('\nStart the app with: npm run dev -> http://localhost:3000');
|
||||
}
|
||||
|
||||
|
||||
233
scripts/seed/content.ts
Normal file
233
scripts/seed/content.ts
Normal file
@ -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;
|
||||
}
|
||||
203
scripts/seed/generators.ts
Normal file
203
scripts/seed/generators.ts
Normal file
@ -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<string, number>; activeMemberIds: number[] } {
|
||||
const seeds = buildUserSeeds(rng);
|
||||
const emailToId = new Map<string, number>();
|
||||
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 };
|
||||
}
|
||||
170
scripts/seed/helpers.ts
Normal file
170
scripts/seed/helpers.ts
Normal file
@ -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<string, unknown> = {}
|
||||
): 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() }
|
||||
);
|
||||
}
|
||||
70
scripts/seed/logs.ts
Normal file
70
scripts/seed/logs.ts
Normal file
@ -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<string, number[]> = {
|
||||
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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
374
scripts/seed/pools.ts
Normal file
374
scripts/seed/pools.ts
Normal file
@ -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');
|
||||
}
|
||||
50
scripts/seed/rng.ts
Normal file
50
scripts/seed/rng.ts
Normal file
@ -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<T>(rng: Rng, arr: readonly T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]!;
|
||||
}
|
||||
|
||||
export function pickN<T>(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<T>(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()}`;
|
||||
}
|
||||
138
scripts/seed/schedule.ts
Normal file
138
scripts/seed/schedule.ts
Normal file
@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user