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:
Ken Yasue
2026-06-25 14:35:56 +02:00
parent 0756bba2e5
commit 571b8ae0c4
8 changed files with 1245 additions and 678 deletions

233
scripts/seed/content.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}