3 Commits

Author SHA1 Message Date
78a7e5ec18 docs(readme): add project summary, features, and tech highlights
Some checks failed
CI / test (push) Has been cancelled
Expand the README intro with a stronger project summary (incl. GLM-5.2/OpenCode origin, ~4M input / ~800K output tokens, ~$82), a Features list, and a Tech Highlights section (SSE, custom migration system, Playwright E2E, Vitest, etc.). Existing content continues below.
2026-06-25 14:49:24 +02:00
2053466b79 docs(readme): add animated demo GIF
Some checks failed
CI / test (push) Has been cancelled
Generate docs/screenshots/demo.gif from the 5 screenshots (1200x600, 3s per frame, two-pass palette) and surface it at the top of README.md.
2026-06-25 14:36:06 +02:00
571b8ae0c4 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.
2026-06-25 14:35:56 +02:00
15 changed files with 1288 additions and 682 deletions

View File

@ -1,9 +1,48 @@
# OpenGroupware
A simple, self-contained, project-based groupware built with **Next.js 15**, **TypeScript**, and
**SQLite**. It runs entirely on Node.js (no external database or storage) and provides boards,
Markdown notes, realtime chat (SSE), a Kanban ToDo board, file sharing, a calendar with milestones,
meetings with schedule-conflict detection, search, dashboards, and admin backups.
![OpenGroupware demo](docs/screenshots/demo.gif)
OpenGroupware is a full-featured, self-contained, project-based groupware built with **Next.js 15**,
**TypeScript**, and **SQLite**. It runs entirely on Node.js — no external database, cache, or object
storage — and packs boards, realtime chat, a Kanban ToDo board, Markdown notes, file sharing, a
calendar with milestones, meetings with schedule-conflict detection, search, dashboards,
notifications, and admin backups into a single deployable app.
> **Origin:** This project was built as a test of **GLM-5.2** with **OpenCode**. The vast majority of
> the codebase was generated autonomously, without human interaction. Total usage: ~4M input tokens,
> ~800K output tokens, ~**$82** in API cost.
## Features
- **Project workspaces** — multi-project, with member roles (admin / member / guest) and permission-checked access throughout
- **Boards** — threaded discussions with Markdown, categories, pinning, important flags, and file/image attachments
- **Realtime chat** — per-project SSE live messages with @mentions and attachments (auto-reconnect via `EventSource`)
- **Kanban ToDo board** — drag-and-drop columns & cards, priorities, assignees, start/due dates, tags, milestones, and attachments
- **Markdown notes** — pinned notes, tags, full search, and sanitized rendering
- **File sharing** — uploads with a lightbox, library + attachment sources, and project-scoped storage
- **Calendar** — month/week/day views with events linked to todos, milestones, and meetings
- **Milestones** — due dates, status, and progress roll-up across todos
- **Meetings** — agenda & minutes (Markdown), invited members, and schedule-conflict detection
- **Search & dashboards** — cross-project search and a personal dashboard
- **Notifications & activity log** — mentions, assignments, due-soon, and meeting invites, plus a per-project audit trail
- **Admin tools** — one-click backup (SQLite DB + uploads → ZIP), download, and migration status (system_admin only)
- **Auth** — bcrypt password hashing, stateless signed-cookie sessions, and system_admin / member roles
- **PWA & mobile** — installable, responsive, with an offline shell
- **Theme & i18n** — dark/light theme and English / Japanese UI
## Tech Highlights
- **Strict layered architecture** — `UI (app/) → Service (services/) → Repository (repositories/) → Data (lib/db/)`; one-way dependencies, SQL isolated in repositories
- **Realtime via SSE** — a tiny in-memory `SseHub` (`lib/sse/hub.ts`) broadcasts events to per-project clients; services inject the hub and fire events on writes
- **Custom SQL migration system** — `Migrator` (`lib/db/migrator.ts`) applies versioned `lib/db/migrations/NNN_*.sql` files in filename order, each in its own transaction and recorded in `schema_migrations` — no ORM, no external CLI
- **SQLite wrapper** — `SqliteDatabase` centralizes `better-sqlite3` access with WAL mode, `foreign_keys = ON`, parameter binding, and transactions; repositories never touch the driver directly
- **Stateless signed sessions** — `base64url(payload).base64url(hmacSha256(secret, payload))` cookies verified with `crypto.timingSafeEqual` and an `iat` expiry
- **Playwright E2E tests** — `webServer` auto-runs `migrate && dev`, a `globalSetup` seeds the admin account, `workers: 1` for deterministic SSE; 15 specs cover every major user flow
- **Vitest unit & integration tests** — 326 tests across 38 files using a real temporary SQLite DB per test, with an 80% coverage threshold on repositories and services
- **Soft deletes everywhere** — `deleted_at IS NULL` filtering enforced in repository queries
- **XSS-safe Markdown** — `react-markdown` + `remark-gfm` + `rehype-sanitize`; HTML and dangerous URL schemes are stripped
- **Key algorithms, unit-tested** — schedule-conflict detection, milestone progress calculation, notification targeting, and session-token verification
- **Spec-driven workflow** — opencode-driven design docs (`docs/`) and per-task steering files (`.steering/`) keep planning and implementation traceable
The codebase follows a strict **layered architecture**:

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

BIN
docs/screenshots/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@ -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
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;
}