5 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
0756bba2e5 Merge branch 'feature/fix-chat-immediate-todo-reorder' - chat immediate display + todo reorder
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 13:06:18 +02:00
03cf27a148 fix(chat,todo): show sent chat messages immediately + kanban drag-and-drop reorder
チャット送信の即時表示とKanbanのDnD並べ替えを修正。

- chat: 送信成功時に返却メッセージを楽観的にローカルへ先頭追加(SSE到着前でも即表示、idで重複排除)。setSending(false) をレスポンス処理後に移動し二重送信窓を縮小。
- todo: TodoService.reorderItems(projectId, columnId, itemIds[]) を追加(1トランザクションでカラム内を再採番、メンバーシップ+同プロジェクト検証、活動ログ/SSEは1件)。POST /todos/items/reorder を追加。
- KanbanBoard: カード単位のドロップターゲット(ポインタYで前/後挿入)+カラム空き領域は末尾追加。楽観的にorderIndex/columnIdを更新しrenderをorderIndexでソート(即時反映)、失敗時はエラー表示+再取得で巻き戻し。ドラッグ直後の誤クリック(ダイアログ誘起)をdragEndタイムスタンプで抑制。空の並べ替えはSSE/ログを出さない。
2026-06-25 13:06:05 +02:00
21 changed files with 1729 additions and 808 deletions

View File

@ -1,9 +1,48 @@
# OpenGroupware # OpenGroupware
A simple, self-contained, project-based groupware built with **Next.js 15**, **TypeScript**, and ![OpenGroupware demo](docs/screenshots/demo.gif)
**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, OpenGroupware is a full-featured, self-contained, project-based groupware built with **Next.js 15**,
meetings with schedule-conflict detection, search, dashboards, and admin backups. **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**: The codebase follows a strict **layered architecture**:

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createTodoService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
/**
* カラム内のアイテム順序を一括再採番する(同一カラムの並べ替え / 他カラムからの移動)。
* body: { columnId: number, itemIds: number[] } —— itemIds の順序で orderIndex 0..n-1 を割り当てる。
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const columnId = Number(body.columnId);
const itemIds = Array.isArray(body.itemIds)
? body.itemIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const service = createTodoService();
try {
const items = service.reorderItems(
user.id,
Number(projectId),
columnId,
itemIds
);
return NextResponse.json({ items });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -81,8 +81,19 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: input, fileIds }), body: JSON.stringify({ body: input, fileIds }),
}); });
setSending(false);
if (res.ok) { if (res.ok) {
// 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。
// SSEの chat.message.created は id で重複排除する。
const data = (await res.json().catch(() => null)) as {
message?: ChatMessageWithAttachments;
} | null;
if (data?.message) {
setMessages((prev) =>
prev.some((m) => m.id === data.message!.id)
? prev
: [data.message!, ...prev]
);
}
setInput(''); setInput('');
pickerRef.current?.clear(); pickerRef.current?.clear();
} else { } else {
@ -91,6 +102,7 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
} | null; } | null;
setError(b?.error?.message ?? '送信に失敗しました'); setError(b?.error?.message ?? '送信に失敗しました');
} }
setSending(false);
} }
return ( return (

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useState, type DragEvent } from 'react'; import { useCallback, useRef, useState, type DragEvent } from 'react';
import type { TodoColumn, TodoItem } from '@/lib/types'; import type { TodoColumn, TodoItem } from '@/lib/types';
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository'; import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
import { TodoDialog } from '@/components/todo/TodoDialog'; import { TodoDialog } from '@/components/todo/TodoDialog';
@ -11,6 +11,11 @@ const PRIORITY_COLORS: Record<string, string> = {
low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300', low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',
}; };
/** 同一 orderIndex は id で安定ソート(createdAt 同着対策) */
function byOrder(a: TodoItem, b: TodoItem): number {
return a.orderIndex - b.orderIndex || a.id - b.id;
}
export function KanbanBoard({ export function KanbanBoard({
projectId, projectId,
columns, columns,
@ -25,6 +30,9 @@ export function KanbanBoard({
const [items, setItems] = useState<TodoItem[]>(initialItems); const [items, setItems] = useState<TodoItem[]>(initialItems);
const [draggingId, setDraggingId] = useState<number | null>(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null); const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
const [error, setError] = useState<string | null>(null);
// ドラッグ直後の誤クリック(ダイアログを開く)を抑制するためのタイムスタンプ
const lastDragEndAtRef = useRef(0);
const reload = useCallback(async () => { const reload = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/todos/items`); const res = await fetch(`/api/projects/${projectId}/todos/items`);
@ -40,27 +48,84 @@ export function KanbanBoard({
e.dataTransfer.setData('text/plain', String(itemId)); e.dataTransfer.setData('text/plain', String(itemId));
} }
function applyReorder(
prev: TodoItem[],
destColumnId: number,
newOrderIds: number[]
): TodoItem[] {
const order = new Map(newOrderIds.map((id, idx) => [id, idx]));
return prev.map((i) =>
order.has(i.id)
? { ...i, columnId: destColumnId, orderIndex: order.get(i.id)! }
: i
);
}
function columnItemIds(columnId: number, excludeId?: number): number[] {
return items
.filter((i) => i.columnId === columnId && i.id !== excludeId)
.sort((a, b) => a.orderIndex - b.orderIndex || a.id - b.id)
.map((i) => i.id);
}
async function persistReorder(
destColumnId: number,
newOrderIds: number[]
): Promise<void> {
setItems((prev) => applyReorder(prev, destColumnId, newOrderIds));
setError(null);
try {
const res = await fetch(
`/api/projects/${projectId}/todos/items/reorder`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
columnId: destColumnId,
itemIds: newOrderIds,
}),
}
);
if (!res.ok) {
setError('並べ替えに失敗しました');
}
} catch {
setError('並べ替えに失敗しました');
} finally {
await reload();
}
setDraggingId(null);
}
// カラムの空き領域へのドロップ = 末尾に追加
function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) { function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
e.preventDefault(); e.preventDefault();
const itemId = Number(e.dataTransfer.getData('text/plain')); const itemId = Number(e.dataTransfer.getData('text/plain'));
if (!itemId) return; if (!itemId) {
const item = items.find((i) => i.id === itemId);
if (!item || item.columnId === column.id) {
setDraggingId(null); setDraggingId(null);
return; return;
} }
// 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す) const newOrderIds = [...columnItemIds(column.id, itemId), itemId];
setItems((prev) => void persistReorder(column.id, newOrderIds);
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) }
);
fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { // カード上へのドロップ = ポインタ位置で前後に挿入
method: 'PATCH', function dropOnItem(e: DragEvent<HTMLElement>, target: TodoItem) {
headers: { 'Content-Type': 'application/json' }, e.preventDefault();
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), e.stopPropagation();
}) const itemId = Number(e.dataTransfer.getData('text/plain'));
.catch(() => undefined) if (!itemId || itemId === target.id) {
.finally(() => reload()); setDraggingId(null);
setDraggingId(null); return;
}
const rect = e.currentTarget.getBoundingClientRect();
const before = e.clientY - rect.top < rect.height / 2;
const ordered = columnItemIds(target.columnId, itemId);
const insertAt = before
? ordered.indexOf(target.id)
: ordered.indexOf(target.id) + 1;
ordered.splice(insertAt, 0, itemId);
void persistReorder(target.columnId, ordered);
} }
async function addTask(columnId: number, title: string) { async function addTask(columnId: number, title: string) {
@ -82,116 +147,142 @@ export function KanbanBoard({
} }
return ( return (
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board"> <>
{columns.map((column) => { {error && (
const colItems = items.filter((i) => i.columnId === column.id); <p
return ( className="mb-2 text-sm text-red-600"
<section role="alert"
key={column.id} data-testid="kanban-error"
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3" >
onDragOver={(e) => e.preventDefault()} {error}
onDrop={(e) => onDrop(e, column)} </p>
data-testid={`kanban-column-${column.id}`}
data-column-id={column.id}
>
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.name} ({colItems.length})
</h2>
<ul className="space-y-2">
{colItems.map((item) => {
const tags = item.tags
?.split(',')
.map((t) => t.trim())
.filter(Boolean);
const assignee = members.find(
(m) => m.user.id === item.assigneeId
);
return (
<li
key={item.id}
draggable
onDragStart={(e) => onDragStart(e, item.id)}
onClick={() => setSelectedItem(item)}
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
draggingId === item.id ? 'opacity-50' : ''
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
data-testid={`todo-card-${item.id}`}
>
<div className="flex items-start justify-between gap-2">
<span
className="text-sm hover:text-blue-600 hover:underline"
data-testid={`todo-open-${item.id}`}
>
{item.title}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`}
>
{item.completedAt ? '戻す' : '完了'}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
PRIORITY_COLORS[item.priority] ??
PRIORITY_COLORS.normal
}`}
>
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate}
</span>
)}
{item.startDate && (
<span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name}
</span>
)}
</div>
{tags && tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t}
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
>
{t}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section>
);
})}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)} )}
</div> <div
className="flex gap-4 overflow-x-auto pb-4"
data-testid="kanban-board"
>
{columns.map((column) => {
const colItems = items
.filter((i) => i.columnId === column.id)
.sort(byOrder);
return (
<section
key={column.id}
className="w-64 shrink-0 rounded-lg bg-gray-100 dark:bg-gray-700 p-3"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, column)}
data-testid={`kanban-column-${column.id}`}
data-column-id={column.id}
>
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.name} ({colItems.length})
</h2>
<ul className="space-y-2">
{colItems.map((item) => {
const tags = item.tags
?.split(',')
.map((t) => t.trim())
.filter(Boolean);
const assignee = members.find(
(m) => m.user.id === item.assigneeId
);
return (
<li
key={item.id}
draggable
onDragStart={(e) => onDragStart(e, item.id)}
onDragEnd={() => {
lastDragEndAtRef.current = Date.now();
setDraggingId(null);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => dropOnItem(e, item)}
onClick={() => {
// ドラッグ直後の誤クリックでダイアログが開かないようにする
if (Date.now() - lastDragEndAtRef.current < 300) return;
setSelectedItem(item);
}}
className={`cursor-grab rounded border bg-white dark:bg-gray-800 p-2 shadow-sm ${
draggingId === item.id ? 'opacity-50' : ''
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
data-testid={`todo-card-${item.id}`}
>
<div className="flex items-start justify-between gap-2">
<span
className="text-sm hover:text-blue-600 hover:underline"
data-testid={`todo-open-${item.id}`}
>
{item.title}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`}
>
{item.completedAt ? '戻す' : '完了'}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
PRIORITY_COLORS[item.priority] ??
PRIORITY_COLORS.normal
}`}
>
{item.priority}
</span>
{item.dueDate && (
<span className="text-xs text-gray-500 dark:text-gray-400">
: {item.dueDate}
</span>
)}
{item.startDate && (
<span className="text-xs text-gray-400 dark:text-gray-500">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500 dark:text-gray-400">
@{assignee.user.name}
</span>
)}
</div>
{tags && tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t}
className="rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-600 dark:text-gray-300"
>
{t}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section>
);
})}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)}
</div>
</>
); );
} }

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ディレクトリを削除してから再作成します * 注意: 既存のDBファイルとuploadsディレクトリを削除してから再作成します
* (デモ用途のため、冪等に再実行できるようにリセット方式を採用)。 * (デモ用途のため、冪等に再実行できるようにリセット方式を採用)。
* *
* 投入内容: 管理者1名 + 一般ユーザー5名、プロジェクト3件、各プロジェクトに * 投入内容: 管理者1名 + 一般ユーザー約29名、プロジェクト約15件、各プロジェクトに
* 掲示板/チャット/ToDo/メモ/ファイル/カレンダー/マイルストーン/ミーティング/ * 掲示板/チャット/ToDo/メモ/ファイル/カレンダー/マイルストーン/ミーティング/
* 通知/アクティビティログを網羅。 * 通知/アクティビティログを網羅(従来比 約5倍)。生成内容は固定シードで決定論的
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import bcrypt from 'bcrypt';
import { SqliteDatabase } from '../lib/db/sqlite'; import { SqliteDatabase } from '../lib/db/sqlite';
import { Migrator } from '../lib/db/migrator'; import { Migrator } from '../lib/db/migrator';
import { seedAll } from './seed/generators';
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);
}
function resetStorage(dbPath: string, uploadsDir: string): void { function resetStorage(dbPath: string, uploadsDir: string): void {
for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { 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 }); 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 { function main(): void {
const dbPath = process.env.SQLITE_PATH ?? './data/app.db'; const dbPath = process.env.SQLITE_PATH ?? './data/app.db';
const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads'; const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads';
@ -675,54 +40,18 @@ function main(): void {
path.join(process.cwd(), 'lib', 'db', 'migrations') path.join(process.cwd(), 'lib', 'db', 'migrations')
).migrate(); ).migrate();
console.log('Seeding users...'); console.log('Seeding demo data (~5x volume)...');
const userIds = seedUsers(db); const { userCount, projectCount } = seedAll(db, uploadsDir);
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++;
}
db.close(); db.close();
console.log('\n=== Seed complete ==='); console.log('\n=== Seed complete ===');
console.log(`Users: ${USERS.length} | Projects: ${projectCount}`); console.log(`Users: ${userCount} | Projects: ${projectCount}`);
console.log('\nLogin credentials:'); console.log('\nLogin credentials:');
console.log(' Admin: admin@example.com / admin123 (system_admin)'); console.log(' Admin: admin@example.com / admin123 (system_admin)');
console.log(' Users: alice / bob / carol / dave @example.com / password'); console.log(' Users: alice / bob / carol / dave @example.com / password');
console.log(' Inactive: eve@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'); 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;
}

View File

@ -235,6 +235,45 @@ export class TodoService {
return updated; return updated;
} }
/**
* 指定カラム内のアイテム順序を itemIds の順で再採番する。
* 同一カラムの並べ替えだけでなく、別カラムからの移動(対象カラムへの挿入)にも使う。
* すべて同一プロジェクト内であることを検証し、1トランザクションで更新する。
*/
reorderItems(
actorId: number,
projectId: number,
columnId: number,
itemIds: number[]
): TodoItem[] {
this.requireMember(projectId, actorId);
const column = this.todoRepository.findColumnById(columnId);
if (!column || column.projectId !== projectId) {
throw new NotFoundError('TodoColumn', columnId);
}
const ids = Array.isArray(itemIds) ? itemIds : [];
if (ids.length === 0) {
// 空の並べ替えは何も変更せず、SSE/ログも出さない
return this.todoRepository
.findItemsByProject(projectId)
.filter((i) => i.columnId === columnId);
}
this.db.transaction(() => {
ids.forEach((id, idx) => {
const item = this.todoRepository.findItemById(id);
if (!item || item.projectId !== projectId) {
throw new NotFoundError('TodoItem', id);
}
this.todoRepository.updateItem(id, { columnId, orderIndex: idx });
});
});
this.logTodo(projectId, actorId, 'todo_updated', ids[0]);
this.broadcastTodo(projectId);
return this.todoRepository
.findItemsByProject(projectId)
.filter((i) => i.columnId === columnId);
}
toggleComplete(actorId: number, itemId: number): TodoItem { toggleComplete(actorId: number, itemId: number): TodoItem {
const item = this.todoRepository.findItemById(itemId); const item = this.todoRepository.findItemById(itemId);
if (!item) throw new NotFoundError('TodoItem', itemId); if (!item) throw new NotFoundError('TodoItem', itemId);

View File

@ -171,4 +171,58 @@ test.describe('todo / kanban', () => {
await expect(page.getByTestId('todo-dialog')).toBeVisible(); await expect(page.getByTestId('todo-dialog')).toBeVisible();
await expect(page.getByTestId('attachment-list')).toBeVisible(); await expect(page.getByTestId('attachment-list')).toBeVisible();
}); });
test('reorder cards within a column by drag and drop and persist', async ({
page,
}) => {
const projectId = await setupOwner(page);
const colsRes = await page.request.get(
`/api/projects/${projectId}/todos/columns`
);
const cols = (
(await colsRes.json()) as { columns: { id: number; name: string }[] }
).columns;
const backlog = cols.find((c) => c.name === 'Backlog')!;
// 3 つのタスクを Backlog に作成(作成順 A, B, C)
const titles = ['Task-A', 'Task-B', 'Task-C'];
const ids: number[] = [];
for (const title of titles) {
const res = await page.request.post(
`/api/projects/${projectId}/todos/items`,
{ data: { title, columnId: backlog.id } }
);
const { item } = (await res.json()) as { item: { id: number } };
ids.push(item.id);
}
const [aId, bId, cId] = ids;
await page.goto(`/projects/${projectId}/todos`);
const col = page.getByTestId(`kanban-column-${backlog.id}`);
async function orderIds(): Promise<number[]> {
return col
.locator('[data-testid^="todo-open-"]')
.evaluateAll((els) =>
els.map((e) => Number(e.getAttribute('data-testid')!.slice(10)))
);
}
await expect(col.getByTestId(`todo-card-${cId}`)).toBeVisible();
// C を A の上にドラッグ(前へ挿入) -> 順序は C, A, B
await page
.getByTestId(`todo-card-${cId}`)
.dragTo(page.getByTestId(`todo-card-${aId}`), {
targetPosition: { x: 10, y: 2 },
});
await expect
.poll(async () => (await orderIds())[0], { timeout: 10_000 })
.toBe(cId);
expect(await orderIds()).toEqual([cId, aId, bId]);
// リロード後も順序が維持される
await page.reload();
expect(await orderIds()).toEqual([cId, aId, bId]);
});
}); });

View File

@ -308,4 +308,77 @@ describe('TodoService', () => {
expect(cleared.tags).toBeNull(); expect(cleared.tags).toBeNull();
expect(cleared.assigneeId).toBeNull(); expect(cleared.assigneeId).toBeNull();
}); });
describe('reorderItems', () => {
it('renumbers items within a column in the given order', () => {
const cols = service.getColumns(authorId, projectId);
const col = cols[0];
const a = service.createItem(authorId, projectId, {
title: 'A',
columnId: col.id,
});
const b = service.createItem(authorId, projectId, {
title: 'B',
columnId: col.id,
});
const c = service.createItem(authorId, projectId, {
title: 'C',
columnId: col.id,
});
// 逆順に並べ替え
const result = service.reorderItems(authorId, projectId, col.id, [
c.id,
b.id,
a.id,
]);
expect(result.map((i) => i.title)).toEqual(['C', 'B', 'A']);
expect(result.map((i) => i.orderIndex)).toEqual([0, 1, 2]);
});
it('moves an item from another column into the target position', () => {
const cols = service.getColumns(authorId, projectId);
const src = cols[0];
const dest = cols[1];
const a = service.createItem(authorId, projectId, {
title: 'A',
columnId: dest.id,
});
const b = service.createItem(authorId, projectId, {
title: 'B',
columnId: dest.id,
});
const x = service.createItem(authorId, projectId, {
title: 'X',
columnId: src.id,
});
// X を A と B の間に挿入
service.reorderItems(authorId, projectId, dest.id, [a.id, x.id, b.id]);
const destItems = service
.getItems(authorId, projectId)
.filter((i) => i.columnId === dest.id);
expect(destItems.map((i) => i.title)).toEqual(['A', 'X', 'B']);
// X は元のカラムからいなくなる
const srcItems = service
.getItems(authorId, projectId)
.filter((i) => i.columnId === src.id);
expect(srcItems.find((i) => i.id === x.id)).toBeUndefined();
});
it('forbids a non-member from reordering', () => {
const col = service.getColumns(authorId, projectId)[0];
expect(() =>
service.reorderItems(outsiderId, projectId, col.id, [])
).toThrow(ForbiddenError);
});
it('rejects an unknown column or item', () => {
const col = service.getColumns(authorId, projectId)[0];
expect(() =>
service.reorderItems(authorId, projectId, 99999, [])
).toThrow(NotFoundError);
expect(() =>
service.reorderItems(authorId, projectId, col.id, [99999])
).toThrow(NotFoundError);
});
});
}); });