diff --git a/README.md b/README.md index a960324..1ea38e5 100644 --- a/README.md +++ b/README.md @@ -1,396 +1,288 @@ -# opencode Spec-Driven Development Boilerplate +# OpenGroupware -A spec-driven development boilerplate for [opencode](https://opencode.ai), converted from the -[Claude Code boilerplate](https://git.yasue.org/ken/claudecode-boilerplate) that accompanies the book -*"Practical Claude Code Introduction."* +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. -It gives opencode a structured workflow for turning ideas into production code through persistent design -documents, per-task steering files, AI skills, subagents, and slash commands. +The codebase follows a strict **layered architecture**: + +``` +UI (app/) → Service (services/) → Repository (repositories/) → Data (lib/db/) +``` + +Dependencies flow one direction only; SQL is always parameter-bound and accessed through the +`SqliteDatabase` wrapper (never `better-sqlite3` directly in repositories). --- ## Table of Contents -- [What Is Spec-Driven Development?](#what-is-spec-driven-development) -- [Conversion Summary (Claude Code → opencode)](#conversion-summary-claude-code--opencode) - [Prerequisites](#prerequisites) -- [Quick Start](#quick-start) -- [Directory Structure](#directory-structure) -- [Configuration Overview](#configuration-overview) -- [Usage Workflow](#usage-workflow) -- [Commands Reference](#commands-reference) -- [Skills Reference](#skills-reference) -- [Agents Reference](#agents-reference) -- [Customizing the Boilerplate](#customizing-the-boilerplate) +- [How to Start](#how-to-start) +- [How to Test](#how-to-test) +- [How to Develop](#how-to-develop) +- [Environment Variables](#environment-variables) +- [Project Structure](#project-structure) +- [npm Scripts Reference](#npm-scripts-reference) - [Troubleshooting](#troubleshooting) - [License](#license) --- -## What Is Spec-Driven Development? - -Spec-driven development separates **"what to build"** from **"how to build it"**: - -1. **Persistent documents** (`docs/`) — the north-star design (PRD, functional design, architecture, etc.) -2. **Steering files** (`.steering/`) — per-task plans created fresh for each piece of work -3. **Implementation** — the agent follows `tasklist.md`, updating progress as it goes -4. **Verification** — tests, lint, typecheck, and a retrospective - -opencode loads the right skill automatically based on what you're doing, so you don't have to think about -the process — just describe what you want. - ---- - -## Conversion Summary (Claude Code → opencode) - -| Claude Code | opencode | Notes | -|---|---|---| -| `CLAUDE.md` | `AGENTS.md` | Referenced via `instructions` in `opencode.json` | -| `.claude/settings.json` | `opencode.json` | Permissions converted to opencode's `permission` schema | -| `.claude/agents/*.md` | `.opencode/agent/*.md` | Added `mode: subagent`; `model: sonnet` removed (inherits default) | -| `.claude/commands/*.md` | `.opencode/command/*.md` | Tool-call syntax (`Bash()`, `Grep()`, `Skill()`) converted to natural instructions; `$ARGUMENTS` added | -| `.claude/skills/*/SKILL.md` | `.opencode/skills/*/SKILL.md` | `allowed-tools` frontmatter removed (not an opencode field) | -| `Skill('steering')` calls | Auto-loaded by description | opencode surfaces skills via `description` matching | -| `TodoWrite` / `Edit` tool refs | `todowrite` / `edit` | Lowercased to match opencode tool names | -| Claude Code devcontainer feature | `curl … opencode.ai/install` | Replaced Anthropic feature with opencode install script | - -All skill templates and guides were preserved verbatim except for `.claude/` path references and -"Claude Code" mentions, which were updated to `.opencode/` and "opencode" respectively. - ---- - ## Prerequisites -- **Node.js** v18+ (v24.11.0 recommended) -- **npm** -- **opencode** — install with: - ```bash - curl -fsSL https://opencode.ai/install | bash - ``` - See for alternatives (npm, Homebrew, etc.) -- **Docker** (only if using the Dev Container) +- **Node.js** v24.11.0 (LTS; v18+ works) — the dev container pins this version +- **npm** 11.x (bundled with Node.js) +- **Playwright browsers** for E2E tests (see [How to Test](#how-to-test)) + +A Dev Container configuration (`.devcontainer/`) is included for VS Code. --- -## Quick Start - -### Option A — Dev Container (recommended) - -1. Open this folder in **VS Code**. -2. When prompted, click **"Reopen in Container"** (or run the command - *Dev Containers: Reopen in Container*). -3. The container automatically: - - Installs Node.js LTS - - Runs `npm install` - - Installs opencode -4. Open a terminal and start opencode: - ```bash - opencode - ``` - -### Option B — Manual setup +## How to Start ```bash # 1. Install dependencies npm install -# 2. Set up Git hooks -npm run prepare +# 2. Create your local environment file +cp .env.example .env +# Edit .env if you want to change SQLITE_PATH / SESSION_SECRET / UPLOADS_PATH. +# SESSION_SECRET is required — the app will not start without it. -# 3. Start opencode -opencode +# 3. Initialize the database (runs all SQL migrations) +npm run migrate + +# 4. Start the development server +npm run dev +``` + +The app is then available at **http://localhost:3000**. + +- The first user you register becomes a regular `member`. To use admin features + (backups, migration status), promote a user to `system_admin` in the DB, or seed one: + ```bash + npx tsx -e "import('./lib/db/sqlite.ts').then(async m => { \ + const { UserRepository } = await import('./repositories/UserRepository.ts'); \ + const bcrypt = (await import('bcrypt')).default; \ + const db = new m.SqliteDatabase(process.env.SQLITE_PATH ?? './data/app.db'); \ + new UserRepository(db).create({ name:'Admin', email:'admin@example.com', \ + passwordHash: bcrypt.hashSync('admin123', 10), role:'system_admin' }); db.close(); })" + ``` +- The SQLite database lives at `./data/app.db` (and uploads under `./data/uploads/`). Both are + git-ignored. Deleting `./data/` and re-running `npm run migrate` gives a clean slate. + +### Production build + +```bash +npm run build +npm run start # serves the optimized build on http://localhost:3000 ``` --- -## Directory Structure +## How to Test + +There are three test layers: **Unit/Integration** (Vitest) and **E2E** (Playwright). + +### Unit & integration tests (Vitest) + +```bash +npm test # run all unit/integration tests once +npm run test:watch # watch mode +npm run test:coverage # run with coverage (threshold: 80% for repositories/** + services/**) +``` + +These use a real temporary SQLite database per test (see `tests/helpers/db.ts`) and cover every +Repository, Service, and the key algorithms (schedule-conflict, milestone progress, session tokens, +notification targeting). + +### End-to-end tests (Playwright) + +```bash +npm run test:e2e # run the full E2E suite (headless) +npm run test:e2e:ui # interactive UI mode — test tree + per-step screenshots/traces +``` + +Before running E2E: + +```bash +# Install the Chromium browser once (per machine) +npx playwright install chromium +``` + +How E2E works: +- Playwright's `webServer` runs `npm run migrate && npm run dev` automatically, so the DB is + initialized and the dev server is started for you. +- A `globalSetup` (`tests/e2e/globalSetup.ts`) seeds an `admin@example.com` / `admin123` + (`system_admin`) account used by the admin/backup tests. +- The suite runs with `workers: 1` for deterministic behavior (the realtime SSE test needs low + contention against the single dev server). +- E2E specs live in `tests/e2e/*.spec.ts` (auth, project-management, board, notes, chat-sse, + todo-kanban, file-sharing, calendar, meetings, search/dashboard, notifications, activity-log, + backup). + +### Lint, type-check, build + +```bash +npm run lint +npm run typecheck +npm run build +``` + +Run all three green before committing. A pre-commit hook (Husky + lint-staged) auto-fixes and +formats staged files. + +### Recommended full check before opening a PR + +```bash +npm run lint && npm run typecheck && npm test && npm run build && npm run test:e2e +``` + +--- + +## How to Develop + +### Typical workflow + +1. Branch from `main`: `git checkout -b feature/` (or `fix/`, `refactor/`). +2. Implement following the layered architecture and coding conventions (see + `docs/development-guidelines.md`): + - Route handlers in `app/api/.../route.ts` start with `export const runtime = 'nodejs';` (Edge + runtime is forbidden because of better-sqlite3). + - Services do permission checks and call repositories; repositories hold SQL and use parameter + binding (`@param`) and `deleted_at IS NULL` for soft-deleted tables. + - Map DB `snake_case` columns to camelCase entity fields in repository mappers. + - All list endpoints paginate (`LIMIT`/`OFFSET`). +3. Add Unit tests for any new Repository/Service, and an E2E spec for user-facing flows. +4. Ensure `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build` are green. +5. Merge to `main` (merge commit). Milestones in this project were delivered one per branch, each + merged to `main` before the next branched off. + +### Database migrations + +Migrations are plain SQL files in `lib/db/migrations/`, named `NNN_description.sql` and run in +filename order. Each file runs in its own transaction and is recorded in `schema_migrations`. + +```bash +npm run migrate # apply pending migrations +``` + +To add a schema change, create e.g. `lib/db/migrations/002_add_column.sql` and re-run +`npm run migrate`. Never edit an already-applied migration — add a new one. + +### Realtime (SSE) + +`lib/sse/hub.ts` (`SseHub`) keeps an in-memory set of clients per project and broadcasts events only +to that project's clients. Services inject the hub and call `broadcast(projectId, event)`; the +stream endpoint `GET /api/projects/:projectId/chat/stream` registers a client and cleans up on +abort. The chat UI uses `EventSource` (auto-reconnect). + +### Sessions & auth + +Stateless signed-cookie sessions (`lib/auth/session.ts`): the cookie is +`base64url({uid,iat}).base64url(hmacSha256(secret, payload))`, verified server-side with +`crypto.timingSafeEqual` and an `iat` expiry check. `middleware.ts` does a coarse cookie-presence +redirect for pages; the real HMAC + DB verification happens in `getCurrentUser` (Node runtime). + +### Where things live + +| Concern | Location | +|---|---| +| Pages & API routes | `app/` | +| Business logic, permissions | `services/` | +| SQL & data access | `repositories/` | +| DB wrapper, migrations, SSE, auth, types, validators | `lib/` | +| React components | `components/` | +| Tests | `tests/unit`, `tests/integration`, `tests/e2e` | + +### Spec-driven workflow (optional) + +This project also ships an opencode spec-driven workflow (see `AGENTS.md`, `docs/`, `.steering/`, +`.opencode/`). Persistent design docs live in `docs/`; per-task plans live in +`.steering/[YYYYMMDD]-[name]/`. You can drive new work with `/add-feature ` in opencode, but +normal Git + `npm` development works too. + +--- + +## Environment Variables + +Configured via `.env` (copy from `.env.example`): + +| Variable | Default | Purpose | +|---|---|---| +| `SQLITE_PATH` | `./data/app.db` | SQLite database file path | +| `SESSION_SECRET` | _(must set)_ | HMAC secret for signing session cookies | +| `UPLOADS_PATH` | `./data/uploads` | Directory for uploaded files | + +`SESSION_SECRET` must be set — the app throws on startup if it is missing. Never commit `.env`. + +--- + +## Project Structure ``` . -├── opencode.json # opencode config (permissions, instructions) -├── AGENTS.md # Project instructions (loaded into every session) -├── package.json # Node.js project + scripts -├── tsconfig.json # TypeScript config -├── eslint.config.js # ESLint flat config -├── vitest.config.ts # Vitest config (80% coverage threshold) -├── .prettierrc / .prettierignore -├── .gitignore -├── .husky/pre-commit # Runs lint-staged + typecheck before commit -├── .devcontainer/devcontainer.json -├── prompt.md # Example MVP prompt -│ -├── src/ # Source code -│ ├── example.ts -│ └── example.test.ts -│ -├── docs/ # Persistent design documents (created by /setup-project) -│ └── ideas/ -│ └── initial-requirements.md # Brainstorming notes (pre-PRD) -│ -├── .steering/ # Per-task steering files (created by /add-feature) -│ └── .gitkeep -│ -└── .opencode/ # opencode configuration - ├── agent/ # Subagent definitions - │ ├── doc-reviewer.md - │ └── implementation-validator.md - ├── command/ # Slash commands - │ ├── setup-project.md - │ ├── add-feature.md - │ └── review-docs.md - └── skills/ # Task-specific skills - ├── prd-writing/ (SKILL.md + template.md) - ├── functional-design/ (SKILL.md + template.md + guide.md) - ├── architecture-design/ (SKILL.md + template.md + guide.md) - ├── repository-structure/ (SKILL.md + template.md + guide.md) - ├── development-guidelines/(SKILL.md + template.md + guides/) - ├── glossary-creation/ (SKILL.md + template.md + guide.md) - └── steering/ (SKILL.md + templates/) +├── app/ # Next.js App Router: pages + Route Handlers (all runtime='nodejs') +│ ├── api/ # REST/SSE endpoints +│ ├── projects/[projectId]/ # project screens (board, notes, chat, todos, files, calendar, ...) +│ ├── admin/backups/ # admin-only backup screen +│ └── login / profile / dashboard / notifications +├── lib/ +│ ├── db/ # SqliteDatabase, Migrator, migrations/*.sql +│ ├── sse/ # SseHub +│ ├── auth/ # session, getCurrentUser +│ ├── types/ # entity & event types +│ ├── validators/ # input validators +│ ├── api/ # service factories + error handling for route handlers +│ └── errors.ts # AppError hierarchy (ValidationError, ForbiddenError, ...) +├── repositories/ # one class per table (SQL + mapping) +├── services/ # business logic + permission checks + side effects +├── components/ # React components by area (layout, board, chat, todo, ...) +├── tests/ +│ ├── unit/ # Vitest — mirrors source structure +│ ├── integration/ # Vitest — real temp SQLite DB +│ ├── e2e/ # Playwright specs + globalSetup.ts +│ └── helpers/db.ts # createTestDb() / createMigratedTestDb() +├── data/ # git-ignored: app.db, uploads/ +├── backups/ # git-ignored: backup-.zip +├── docs/ # persistent design docs (PRD, architecture, milestones, ...) +└── .steering/ # per-task plans (git-ignored working artifacts) ``` --- -## Configuration Overview +## npm Scripts Reference -### `opencode.json` - -The main configuration file. Key sections: - -```jsonc -{ - "$schema": "https://opencode.ai/config.json", - "instructions": ["AGENTS.md"], // Loaded into every session - "permission": { - "skill": "allow", // Skills are auto-available - "bash": { // Common dev commands pre-approved - "*": "ask", - "npm *": "allow", - "npx *": "allow", - "git *": "allow", - // ... - } - } -} -``` - -> **After editing `opencode.json`, quit and restart opencode** — config is loaded once at startup. - -### `AGENTS.md` - -The project memory file. opencode reads it on every session. It defines: -- The technology stack -- The spec-driven development principles -- The directory structure -- The development process and workflow - -### Agents (`.opencode/agent/`) - -Subagents run in isolated contexts via the `task` tool. They don't consume the main session's context. - -### Commands (`.opencode/command/`) - -Slash commands invoked in the opencode TUI with `/command-name`. Each command is a prompt template -that opencode executes. - -### Skills (`.opencode/skills/`) - -Skills are loaded automatically based on their `description` field. You don't invoke them by name — -opencode surfaces the right skill when the task matches. - ---- - -## Usage Workflow - -### 1. Initial project setup - -Run the setup command to create all six persistent design documents interactively: - -``` -> /setup-project -``` - -This creates (one at a time, with your approval): -1. `docs/product-requirements.md` (PRD) -2. `docs/functional-design.md` -3. `docs/architecture.md` -4. `docs/repository-structure.md` -5. `docs/development-guidelines.md` -6. `docs/glossary.md` - -The PRD is based on the brainstorming notes in `docs/ideas/initial-requirements.md`. Edit that file -first if you have your own idea, or replace it. - -### 2. Add a feature - -``` -> /add-feature User authentication -``` - -This fully autonomous workflow: -1. Creates `.steering/[YYYYMMDD]-[feature-name]/` with `requirements.md`, `design.md`, `tasklist.md` -2. Generates the steering files (planning mode of the steering skill) -3. Implements every task in `tasklist.md`, marking each `[ ]` → `[x]` as it goes -4. Launches the `implementation-validator` subagent for quality review -5. Runs `npm test`, `npm run lint`, `npm run typecheck` -6. Records a retrospective in `tasklist.md` - -### 3. Review a document - -``` -> /review-docs docs/product-requirements.md -``` - -Launches the `doc-reviewer` subagent to evaluate the document from five perspectives: -completeness, clarity, consistency, implementability, and measurability. - -### 4. Day-to-day editing - -You don't always need a command — just ask in normal conversation: - -``` -> Add a new feature to the PRD -> Review the performance requirements in architecture.md -> Add a new domain term to glossary.md -``` - -opencode loads the appropriate skill automatically. - ---- - -## Commands Reference - -| Command | Description | Example | -|---|---|---| -| `/setup-project` | Create the six persistent documents interactively | `/setup-project` | -| `/add-feature` | Implement a feature end-to-end (autonomous) | `/add-feature User profile editing` | -| `/review-docs` | Detailed document review via subagent | `/review-docs docs/architecture.md` | - ---- - -## Skills Reference - -| Skill | When it loads | Output | -|---|---|---| -| `prd-writing` | Creating a Product Requirements Document | `docs/product-requirements.md` | -| `functional-design` | Creating a functional design document | `docs/functional-design.md` | -| `architecture-design` | Designing the system architecture | `docs/architecture.md` | -| `repository-structure` | Defining the directory layout | `docs/repository-structure.md` | -| `development-guidelines` | Creating coding conventions / implementing code | `docs/development-guidelines.md` | -| `glossary-creation` | Creating a project glossary | `docs/glossary.md` | -| `steering` | Planning, implementing, and retrospecting on a task | `.steering/[date]-[name]/` files | - -Each skill folder contains a `SKILL.md` (instructions) plus `template.md` and/or `guide.md` (reference -material the skill points to). - ---- - -## Agents Reference - -| Agent | Mode | Purpose | -|---|---|---| -| `doc-reviewer` | subagent | Reviews document quality across 5 dimensions; outputs a scored report with prioritized improvements | -| `implementation-validator` | subagent | Validates code against the spec; checks quality, test coverage, security, performance; runs lint/test/typecheck | - -Agents are launched via the `task` tool (e.g., by the `/add-feature` and `/review-docs` commands). -They run in an isolated context window. - ---- - -## Customizing the Boilerplate - -### Change the model - -Agents inherit the default model from `opencode.json`. To pin a specific model for a subagent, add -`model` to its frontmatter: - -```yaml ---- -description: ... -mode: subagent -model: anthropic/claude-sonnet-4-6 ---- -``` - -Or set a global default in `opencode.json`: - -```jsonc -{ - "model": "anthropic/claude-sonnet-4-6" -} -``` - -> Model IDs use the `provider/model-id` format. See for the full schema. - -### Adjust permissions - -Edit the `permission` block in `opencode.json`. For example, to allow all bash commands: - -```jsonc -{ - "permission": { "bash": "allow" } -} -``` - -### Add a new skill - -1. Create `.opencode/skills/my-skill/SKILL.md` -2. Add frontmatter with `name` and `description` (the description controls when opencode loads it) -3. Add any companion `template.md` or `guide.md` files -4. Restart opencode - -### Add a new command - -1. Create `.opencode/command/my-command.md` -2. Add `description` frontmatter + a prompt body (use `$ARGUMENTS` for user input) -3. Restart opencode - -### Add a new agent - -1. Create `.opencode/agent/my-agent.md` -2. Add `description` and `mode: subagent` frontmatter + a prompt body -3. Restart opencode +| Script | Description | +|---|---| +| `npm run dev` | Start the Next.js dev server (http://localhost:3000) | +| `npm run build` | Production build | +| `npm run start` | Serve the production build | +| `npm run lint` | ESLint | +| `npm run format` | Prettier (write) | +| `npm run typecheck` | `tsc --noEmit` | +| `npm test` | Run unit/integration tests (Vitest, once) | +| `npm run test:watch` | Vitest watch mode | +| `npm run test:coverage` | Vitest with coverage (80% threshold on repos+services) | +| `npm run test:e2e` | Playwright E2E (headless, full suite) | +| `npm run test:e2e:ui` | Playwright interactive UI mode (screenshots + traces) | +| `npm run migrate` | Apply SQL migrations | --- ## Troubleshooting -### opencode won't start after editing config - -opencode validates `opencode.json` strictly and refuses to start on invalid fields. To recover: - -```bash -# Skip the project config and start from globals only -OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode -``` - -Then fix the broken file and restart normally. - -### Skills not loading - -- Ensure each `SKILL.md` has a `description` in its frontmatter — skills without one are filtered out. -- Ensure the folder name matches the `name` field. -- Restart opencode after adding or changing skills. - -### Commands not appearing - -- Command files must be directly inside `.opencode/command/` (not in subdirectories). -- Restart opencode after adding commands. - -### Dev Container: `opencode` command not found - -The install script adds opencode to `~/.opencode/bin/` and updates your shell profile. If the command -isn't found in a new terminal: - -```bash -export PATH="$HOME/.opencode/bin:$PATH" -opencode -``` - -Or create a permanent symlink: - -```bash -sudo ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode -``` +- **App won't start: `SESSION_SECRET is not configured`** — create `.env` from `.env.example` and + set `SESSION_SECRET`. +- **`npm run migrate` fails with "more than one statement"** — ensure the migrator uses + `SqliteDatabase.exec()` (it does); this is a historical footgun with `better-sqlite3`'s + `prepare()`. +- **E2E: `Executable doesn't exist`** — run `npx playwright install chromium`. +- **E2E: tests flaky/failing** — the suite is pinned to `workers: 1`; ensure no other `npm run dev` + is occupying port 3000, and remove `./data/` before a clean run. +- **Port 3000 busy** — stop the other process (`lsof -ti:3000 | xargs kill`) before running dev/E2E. --- diff --git a/package.json b/package.json index 59e8551..e81b7f1 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test:coverage": "vitest run --coverage", - "migrate": "tsx lib/db/run-migrations.ts" + "migrate": "tsx lib/db/run-migrations.ts", + "seed": "tsx scripts/seed-demo.ts" }, "keywords": [ "groupware", diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts new file mode 100644 index 0000000..b4e5a6c --- /dev/null +++ b/scripts/seed-demo.ts @@ -0,0 +1,729 @@ +/** + * デモデータを一括投入するシードスクリプト。 + * + * 使用方法: + * npm run seed # ./data/app.db をリセットしてデモデータを投入 + * SQLITE_PATH=./other.db npm run seed + * + * 注意: 既存のDBファイルとuploadsディレクトリを削除してから再作成します + * (デモ用途のため、冪等に再実行できるようにリセット方式を採用)。 + * + * 投入内容: 管理者1名 + 一般ユーザー5名、プロジェクト3件、各プロジェクトに + * 掲示板/チャット/ToDo/メモ/ファイル/カレンダー/マイルストーン/ミーティング/ + * 通知/アクティビティログを網羅。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import bcrypt from 'bcrypt'; +import { SqliteDatabase } from '../lib/db/sqlite'; +import { Migrator } from '../lib/db/migrator'; + +const BCRYPT_ROUNDS = 10; +const now = () => new Date().toISOString(); +const daysFromNow = (d: number) => { + const dt = new Date(); + dt.setDate(dt.getDate() + d); + return dt.toISOString(); +}; +const dayStr = (d: number) => daysFromNow(d).slice(0, 10); + +interface UserSeed { + name: string; + email: string; + password: string; + role: 'system_admin' | 'member'; + status: 'active' | 'inactive'; +} + +const USERS: UserSeed[] = [ + { + name: 'Admin User', + email: 'admin@example.com', + password: 'admin123', + role: 'system_admin', + status: 'active', + }, + { + name: 'Alice Tanaka', + email: 'alice@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Bob Sato', + email: 'bob@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Carol Yamada', + email: 'carol@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Dave Suzuki', + email: 'dave@example.com', + password: 'password', + role: 'member', + status: 'active', + }, + { + name: 'Eve Mori (inactive)', + email: 'eve@example.com', + password: 'password', + role: 'member', + status: 'inactive', + }, +]; + +// 1x1 透明PNG(ファイル共有デモ用) +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' +); + +function insert( + db: SqliteDatabase, + sql: string, + params: Record = {} +): number { + return Number(db.execute(sql, params).lastInsertRowid); +} + +function resetStorage(dbPath: string, uploadsDir: string): void { + for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + if (fs.existsSync(p)) fs.unlinkSync(p); + } + if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true }); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + fs.mkdirSync(uploadsDir, { recursive: true }); +} + +function seedUsers(db: SqliteDatabase): Map { + const ids = new Map(); + for (const u of USERS) { + const id = insert( + db, + `INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at) + VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`, + { + name: u.name, + email: u.email, + passwordHash: bcrypt.hashSync(u.password, BCRYPT_ROUNDS), + role: u.role, + status: u.status, + createdAt: now(), + updatedAt: now(), + } + ); + ids.set(u.email, id); + } + return ids; +} + +interface ProjectSeed { + name: string; + description: string; + status: 'active' | 'on_hold' | 'completed' | 'archived'; + owner: string; // email + members: { email: string; role: 'admin' | 'member' | 'guest' }[]; +} + +const PROJECTS: ProjectSeed[] = [ + { + name: 'Website Redesign', + description: 'コーポレートサイトの全面リニューアルプロジェクト', + status: 'active', + owner: 'alice@example.com', + members: [ + { email: 'bob@example.com', role: 'member' }, + { email: 'carol@example.com', role: 'member' }, + ], + }, + { + name: 'Mobile App v2', + description: 'iOS/Androidアプリの次期メジャーバージョン開発', + status: 'active', + owner: 'bob@example.com', + members: [ + { email: 'alice@example.com', role: 'member' }, + { email: 'dave@example.com', role: 'member' }, + ], + }, + { + name: 'Marketing Campaign Q4', + description: '第4四半期マーケティングキャンペーンの企画・実行', + status: 'on_hold', + owner: 'carol@example.com', + members: [ + { email: 'alice@example.com', role: 'member' }, + { email: 'bob@example.com', role: 'member' }, + { email: 'dave@example.com', role: 'guest' }, + ], + }, +]; + +function addMember( + db: SqliteDatabase, + projectId: number, + userId: number, + role: string +): void { + insert( + db, + `INSERT INTO project_members (project_id, user_id, role, joined_at) + VALUES (@projectId, @userId, @role, @joinedAt)`, + { projectId, userId, role, joinedAt: now() } + ); +} + +function seedBoard( + db: SqliteDatabase, + projectId: number, + authorIds: number[] +): void { + const threads = [ + { + title: 'デザイン方針について', + body: '# デザイン方針\n\n- シンプルさを重視\n- モバイルファースト\n\nご意見ください。', + category: 'decision', + pinned: 1, + important: 1, + }, + { + title: '週次進捗報告', + body: '今週の進捗を共有します。\n\n| タスク | 状態 |\n|---|---|\n| トップ画面 | 完了 |\n| About画面 | 進行中 |', + category: 'minutes', + pinned: 0, + important: 0, + }, + { + title: 'FAQ: ログインできない', + body: 'ログインできない場合のトラブルシューティングです。', + category: 'trouble', + pinned: 0, + important: 0, + }, + ]; + for (const t of threads) { + const author = authorIds[0]; + const threadId = insert( + db, + `INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @bodyMd, @authorId, @category, @isPinned, @isImportant, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: t.title, + bodyMd: t.body, + authorId: author, + category: t.category, + isPinned: t.pinned, + isImportant: t.important, + createdAt: now(), + updatedAt: now(), + } + ); + // コメント + const commenter = authorIds[1] ?? author; + insert( + db, + `INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at) + VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`, + { + threadId, + authorId: commenter, + bodyMd: '確認しました。ありがとうございます!', + createdAt: now(), + updatedAt: now(), + } + ); + } +} + +function seedChat( + db: SqliteDatabase, + projectId: number, + authorIds: number[] +): void { + const messages = [ + { author: 0, body: 'おはようございます!今日もよろしくお願いします。' }, + { + author: 1, + body: 'おつですー。 @alice@example.com ちょっと確認したいことあります', + }, + { author: 0, body: '何でしょう?' }, + { author: 1, body: 'デザインのカラーパレット、これで確定で大丈夫ですか?' }, + { author: 0, body: '問題ないです!進めましょう :)' }, + ]; + for (const m of messages) { + insert( + db, + `INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at) + VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`, + { + projectId, + authorId: authorIds[m.author], + body: m.body, + createdAt: now(), + updatedAt: now(), + } + ); + } +} + +function seedTodos( + db: SqliteDatabase, + projectId: number, + memberIds: number[], + milestoneIds: number[] +): void { + const columns = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done']; + const colIds: number[] = []; + columns.forEach((name, idx) => { + colIds.push( + insert( + db, + `INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at) + VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`, + { projectId, name, orderIndex: idx, createdAt: now(), updatedAt: now() } + ) + ); + }); + + const items = [ + { + col: 0, + title: 'アイデア募集: ヘッダー案', + priority: 'low', + assignee: null, + due: null, + milestone: null, + completed: false, + }, + { + col: 1, + title: 'トップ画面の実装', + priority: 'high', + assignee: 0, + due: dayStr(2), + milestone: 0, + completed: false, + }, + { + col: 1, + title: 'About画面の実装', + priority: 'normal', + assignee: 1, + due: dayStr(5), + milestone: 0, + completed: false, + }, + { + col: 2, + title: '問い合わせフォーム', + priority: 'normal', + assignee: 0, + due: dayStr(7), + milestone: 1, + completed: false, + }, + { + col: 3, + title: 'デザインレビュー', + priority: 'high', + assignee: 1, + due: dayStr(1), + milestone: 1, + completed: false, + }, + { + col: 4, + title: '要件定義', + priority: 'normal', + assignee: 0, + due: dayStr(-3), + milestone: 0, + completed: true, + }, + { + col: 4, + title: 'ワイヤフレーム', + priority: 'normal', + assignee: 1, + due: dayStr(-1), + milestone: 0, + completed: true, + }, + ]; + items.forEach((it, idx) => { + const assigneeId = it.assignee === null ? null : memberIds[it.assignee]; + const milestoneId = + it.milestone === null ? null : milestoneIds[it.milestone]; + insert( + db, + `INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, @completedAt, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`, + { + projectId, + columnId: colIds[it.col], + title: it.title, + description: 'デモ用タスクです。', + assigneeId, + creatorId: memberIds[0], + priority: it.priority, + dueDate: it.due, + completedAt: it.completed ? now() : null, + orderIndex: idx, + milestoneId, + createdAt: now(), + updatedAt: now(), + } + ); + }); +} + +function seedNotes( + db: SqliteDatabase, + projectId: number, + authorIds: number[] +): void { + const notes = [ + { + title: 'ミーティングメモ', + body: '# ミーティングメモ\n\n## 決定事項\n- カラーテーマ: 青\n- リリース: 来月\n\n## 宿題\n- [ ] デザイン作成\n- [ ] レビュー', + tags: 'meeting,notes', + pinned: 1, + }, + { + title: '技術メモ', + body: '## 技術スタック\n\n- Next.js 15\n- SQLite\n- Tailwind CSS\n\n```ts\nconst x = 1;\n```', + tags: 'tech', + pinned: 0, + }, + { + title: 'アイデア', + body: '面白い機能のアイデアをメモしておく場所。', + tags: null, + pinned: 0, + }, + ]; + for (const n of notes) { + insert( + db, + `INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @bodyMd, @tags, @isPinned, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: n.title, + bodyMd: n.body, + tags: n.tags, + isPinned: n.pinned, + createdById: authorIds[0], + updatedById: authorIds[0], + createdAt: now(), + updatedAt: now(), + } + ); + } +} + +function seedFiles( + db: SqliteDatabase, + projectId: number, + uploaderId: number, + uploadsDir: string +): void { + const dir = path.join(uploadsDir, String(projectId)); + fs.mkdirSync(dir, { recursive: true }); + + // 画像ファイル + const imgName = `${crypto.randomUUID()}.png`; + const imgPath = path.join(dir, imgName); + fs.writeFileSync(imgPath, PNG_BYTES); + insert( + db, + `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) + VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, + { + projectId, + uploaderId, + filename: imgName, + originalName: 'sample-diagram.png', + mimeType: 'image/png', + size: PNG_BYTES.length, + path: imgPath, + createdAt: now(), + } + ); + + // テキストファイル + const txtName = `${crypto.randomUUID()}.txt`; + const txtPath = path.join(dir, txtName); + const txtContent = + 'これはデモ用テキストファイルです。\nシードスクリプトによって生成されました。'; + fs.writeFileSync(txtPath, txtContent, 'utf-8'); + insert( + db, + `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) + VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, + { + projectId, + uploaderId, + filename: txtName, + originalName: 'spec-notes.txt', + mimeType: 'text/plain', + size: Buffer.byteLength(txtContent), + path: txtPath, + createdAt: now(), + } + ); +} + +function seedMilestones(db: SqliteDatabase, projectId: number): number[] { + const ms = [ + { title: 'M1: デザイン完了', due: dayStr(7) }, + { title: 'M2: 実装完了', due: dayStr(21) }, + ]; + const ids: number[] = []; + for (const m of ms) { + ids.push( + insert( + db, + `INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @dueDate, 'open', @createdAt, @updatedAt, NULL)`, + { + projectId, + title: m.title, + description: 'マイルストーンです。', + dueDate: m.due, + createdAt: now(), + updatedAt: now(), + } + ) + ); + } + return ids; +} + +function seedCalendar( + db: SqliteDatabase, + projectId: number, + creatorId: number +): void { + const events = [ + { + title: '定例ミーティング', + type: 'meeting', + start: daysFromNow(1), + end: daysFromNow(1), + }, + { + title: 'デザインレビュー', + type: 'reminder', + start: daysFromNow(3), + end: null, + }, + { title: 'リリース締切', type: 'deadline', start: dayStr(14), end: null }, + ]; + for (const e of events) { + insert( + db, + `INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, NULL, NULL, NULL, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: e.title, + description: 'カレンダーイベントです。', + type: e.type, + startAt: e.start, + endAt: e.end, + createdById: creatorId, + createdAt: now(), + updatedAt: now(), + } + ); + } +} + +function seedMeetings( + db: SqliteDatabase, + projectId: number, + creatorId: number, + memberIds: number[] +): void { + const meetingId = insert( + db, + `INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, @minutesMd, @createdById, @createdAt, @updatedAt, NULL)`, + { + projectId, + title: '週次定例ミーティング', + description: '進捗確認と課題共有', + location: '会議室A / オンライン', + meetingUrl: 'https://meet.example.com/abc-defg-hij', + startAt: daysFromNow(2), + endAt: daysFromNow(2), + agendaMd: '# アジェンダ\n\n1. 進捗共有\n2. ブロッカー確認\n3. 次週の計画', + minutesMd: '# 議事録\n\n- トップ画面は完了\n- About画面は来週完了予定', + createdById: creatorId, + createdAt: now(), + updatedAt: now(), + } + ); + for (const uid of memberIds) { + insert( + db, + `INSERT INTO meeting_members (meeting_id, user_id, status) VALUES (@meetingId, @userId, 'invited')`, + { meetingId, userId: uid } + ); + } +} + +function seedNotifications( + db: SqliteDatabase, + projectId: number, + memberIds: number[] +): void { + const notifs = [ + { + user: 0, + type: 'mention', + title: 'メンションされました', + body: 'チャットでメンションされました', + }, + { + user: 1, + type: 'todo_assigned', + title: 'ToDoが割り当てられました', + body: 'トップ画面の実装', + }, + { + user: 0, + type: 'meeting_invited', + title: 'ミーティングに招待されました', + body: '週次定例ミーティング', + }, + ]; + for (const n of notifs) { + insert( + db, + `INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at) + VALUES (@userId, @projectId, @type, @title, @body, NULL, @createdAt)`, + { + userId: memberIds[n.user], + projectId, + type: n.type, + title: n.title, + body: n.body, + createdAt: now(), + } + ); + } +} + +function seedActivityLogs( + db: SqliteDatabase, + projectId: number, + actorIds: number[] +): void { + const logs = [ + { actor: 0, action: 'board_posted', targetType: 'thread', targetId: 1 }, + { actor: 1, action: 'comment_added', targetType: 'comment', targetId: 1 }, + { actor: 0, action: 'todo_created', targetType: 'todo', targetId: 1 }, + { actor: 1, action: 'todo_completed', targetType: 'todo', targetId: 6 }, + { actor: 0, action: 'file_uploaded', targetType: 'file', targetId: 1 }, + { actor: 0, action: 'meeting_created', targetType: 'meeting', targetId: 1 }, + { actor: 1, action: 'note_updated', targetType: 'note', targetId: 1 }, + { + actor: 0, + action: 'milestone_updated', + targetType: 'milestone', + targetId: 1, + }, + ]; + for (const l of logs) { + insert( + db, + `INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at) + VALUES (@projectId, @actorId, @action, @targetType, @targetId, NULL, @createdAt)`, + { + projectId, + actorId: actorIds[l.actor], + action: l.action, + targetType: l.targetType, + targetId: l.targetId, + createdAt: now(), + } + ); + } +} + +function main(): void { + const dbPath = process.env.SQLITE_PATH ?? './data/app.db'; + const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads'; + + console.log(`Resetting storage (db: ${dbPath}, uploads: ${uploadsDir})...`); + resetStorage(dbPath, uploadsDir); + + const db = new SqliteDatabase(dbPath); + new Migrator( + db, + 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++; + } + + db.close(); + + console.log('\n=== Seed complete ==='); + console.log(`Users: ${USERS.length} | 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('\nStart the app with: npm run dev -> http://localhost:3000'); +} + +main();