32 Commits

Author SHA1 Message Date
41d65f58bb feat(calendar): replace list view with month/week/day grid + date detail dialog
カレンダー画面を一覧表示から実際のカレンダーグリッドへ変更。

- lib/calendar/grid.ts: 月グリッド/週/時間グリッド/取得範囲(rangeForView)の純粋な日付ヘルパー (+22 unit tests)
- components/calendar/: CalendarView(月/週/日 切替・prev/next/今日)・MonthView(truncateチップ)・WeekView・DayView(時間グリッド+終日)・EventDetailDialog(詳細・フォーカス管理)・sourceColors
- page.tsx: view/date searchParamsから範囲を計算しServer Componentで取得、CalendarViewへ渡す
- e2e: 表示切替・詳細ダイアログ・ナビステップ(週=7日/日=1日)を追加
2026-06-25 08:46:58 +02:00
9fce9e9215 add seeding feature with initial demo data
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 08:09:07 +02:00
f65f3c435c Merge branch 'feature/m15-full-tests' - M15 full test completion & quality 2026-06-25 02:54:51 +02:00
f9720850b2 feat(m15): full test completion + quality gate + validation fixes
- Add E2E notifications.spec (member-added notification -> list -> mark read)
  and activity-log.spec (board_posted + todo_created recorded)
- Set Playwright workers=1 for deterministic full-suite runs (SSE test under
  parallel load); full E2E suite (18 tests / 13 specs incl. 12 mandated
  scenarios) green
- Quality gate: 251 unit/integration tests, lint clean, typecheck clean,
  build succeeds, coverage 91.13% Repository/Service (>=80%)
- Validation fixes (mandatory): SearchService now searches ALL items
  (large pageSize) instead of first page only; MeetingService.
  checkScheduleConflicts now requires membership (actorId) to prevent
  non-member info enumeration
2026-06-25 02:54:48 +02:00
2bc883cb6f Merge branch 'feature/m14-backup-admin' - M14 backup & admin 2026-06-25 02:35:37 +02:00
0026edd22b feat(m14): backup & admin (zip via fflate, admin guard, tests, e2e)
- BackupService (admin-only: createBackup zips DB+uploads via fflate to
  backups/backup-<ts>.zip, listBackups newest-first, getBackupPath with
  path-traversal guard)
- Admin backup APIs: GET/POST /api/admin/backups, GET /api/admin/backups/:filename
- Admin backups screen (admin-only) + BackupCreateButton
- Playwright globalSetup seeds a system_admin user for admin E2E
- add fflate dependency
- Unit tests (BackupService: zip content, list, admin guard, traversal) +
  e2e backup (create/list/download + non-admin 403)
2026-06-25 02:35:35 +02:00
384e61386a Merge branch 'feature/m13-search-dashboard' - M13 search & dashboard 2026-06-25 02:28:20 +02:00
755c242d2b feat(m13): cross-resource search + dashboard completion, tests, e2e
- SearchService (cross-resource keyword search across board/chat/todo/file/
  event/meeting/milestone/note with type filter, project isolation)
- DashboardService (project dashboard: in-progress todos, near-due(7d),
  latest chat/board/notes/files(5), next meeting, milestones w/ progress,
  recent activity(10); personal dashboard: my projects, incomplete todos,
  upcoming meetings, unread notifications, overdue tasks, recent activity)
- CalendarRepository.findByProject
- APIs: GET /api/projects/:projectId/search, GET /api/dashboard
- Screens: complete personal dashboard, complete project overview,
  search page + SearchForm, ProjectNav 検索 link
- Unit tests (SearchService, DashboardService) + e2e dashboard
2026-06-25 02:28:17 +02:00
a632574fb0 Merge branch 'feature/m12-meetings' - M12 meetings & schedule conflict 2026-06-25 02:19:29 +02:00
29e4bc6650 feat(m12): meetings + schedule conflict detection, tests, e2e
- MeetingRepository (CRUD, soft-delete, members, findMeetingsByUserInRange
  overlap) + CalendarRepository.findByCreatorInRange + TodoRepository.
  findHighPriorityByAssignee
- MeetingService (createMeeting tx+members, meeting_invited notify,
  meeting_created activity, SSE meeting.created; updateMeeting/updateMinutes/
  deleteMeeting; checkScheduleConflicts = other meetings overlap + calendar
  events overlap + high-priority todos due +-3d, time-overlap logic,
  excludeMeetingId; conflicts are warnings)
- APIs: meetings list/create/edit + /check conflict check
- Screens: meetings page + MeetingForm(member checkboxes, conflict display)
  + ConflictWarning, ProjectNav link
- Unit tests (MeetingRepository, MeetingService incl. conflict algorithm)
  + e2e meetings
2026-06-25 02:19:25 +02:00
09e1e5e22a Merge branch 'feature/m11-calendar-milestones' - M11 calendar & milestones 2026-06-25 02:11:13 +02:00
31d8ad1325 feat(m11): calendar + milestones with progress calc, tests, e2e
- CalendarRepository (event CRUD, range query, soft-delete, project isolation)
  + MilestoneRepository (CRUD, findToDosByMilestone) + ScheduleService
  (aggregated getCalendarEvents = custom events+milestones+todos in range,
  milestone CRUD, milestone_updated activity, progress auto-calc 0-100)
- APIs: calendar events GET/POST/PATCH/DELETE, milestones GET/POST/PATCH +
  progress
- Screens: calendar list view + CalendarEventForm, milestones list with
  progress bars + MilestoneForm, ProjectNav links
- Unit tests (CalendarRepository, MilestoneRepository, ScheduleService incl.
  progress calc 0/33/100) + e2e calendar
2026-06-25 02:11:10 +02:00
d9d2ba9819 Merge branch 'feature/m10-file-sharing' - M10 file sharing & lightbox 2026-06-25 02:02:42 +02:00
60fef5c0c9 feat(m10): file sharing with upload, lightbox, permission, tests, e2e
- FileRepository (CRUD, soft-delete, pagination, project isolation) +
  FileStorageService (local FS save data/uploads/<projectId>/<uuid>.<ext>,
  MIME allowlist, filename sanitize, unique uuid name, member permission,
  file_shared notification to other members, file_uploaded activity log,
  SSE file.uploaded, uploader/admin delete + FS unlink)
- APIs: files list/upload(multipart), download (membership), delete
- Screens: files page + Uploader(multipart) + FileList(image Lightbox,
  PDF link, delete), ProjectNav ファイル link
- Unit tests (FileRepository, FileStorageService) + e2e file-sharing
2026-06-25 02:02:39 +02:00
1425773cd4 Merge branch 'feature/m9-todo-kanban' - M9 todo/kanban 2026-06-25 01:54:27 +02:00
385b251cc7 feat(m9): todo/kanban with DnD, assignee notify, completion, tests, e2e
- TodoRepository (column CRUD, item CRUD, soft-delete, ordering, maxOrderIndex,
  project isolation) + TodoService (lazy standard-column init, member/admin
  permission, create/update/move/toggleComplete/delete, todo_assigned notify,
  todo_created/updated/completed activity logs, SSE todo.updated)
- APIs: columns + items (GET/POST/PATCH/DELETE), toggleComplete + move
- KanbanBoard client (HTML5 drag&drop move, new task per column, complete),
  todos page, ProjectNav ToDo link
- Unit tests (TodoRepository, TodoService) + e2e todo-kanban
2026-06-25 01:54:24 +02:00
d2a4d56543 Merge branch 'feature/m8-sse-chat' - M8 SSE hub & realtime chat 2026-06-25 01:45:37 +02:00
ddad5ae78c feat(m8): SSE hub + realtime chat with mentions, tests, e2e
- SseHub (project-scoped client mgmt + broadcast, implements SseBroadcaster)
  with SSE-formatted payload; singleton getSseHub
- ChatRepository (message CRUD, soft-delete, search, pagination, project
  isolation) + ChatService (membership permission, send/edit/delete,
  @email mention -> mention notification, SSE broadcast of created/updated/deleted)
- SSE stream endpoint (ReadableStream + heartbeat + abort cleanup, membership)
- Chat message API routes (history/send/edit/delete)
- ChatWindow client (EventSource auto-reconnect, history fetch, realtime
  append/update/delete), chat page, ProjectNav チャット link
- SseEvent type now carries ChatMessage for chat events
- Unit (SseHub, ChatRepository, ChatService) + integration chat-sse-broadcast
  + e2e chat-sse (two contexts realtime)
2026-06-25 01:45:34 +02:00
83a02265f5 Merge branch 'feature/m7-markdown-notes' - M7 markdown notes 2026-06-25 01:36:14 +02:00
e45aea8e27 feat(m7): markdown notes with CRUD, preview, search, tests, e2e
- ProjectNoteRepository (CRUD, soft-delete, search title/body/tags,
  pinned-first ordering, index usage) + NoteService (membership permission,
  author/admin edit/delete, note_updated notification to other members,
  note_created/note_updated activity logs, pin/tags)
- APIs: notes list/create/detail/edit/delete
- Screens: notes list + NoteForm, note detail + NoteEditor + MarkdownBody
  preview, ProjectNav メモ link
- Unit tests (ProjectNoteRepository, NoteService) + e2e markdown-notes.spec
2026-06-25 01:36:10 +02:00
fb61a7f592 Merge branch 'feature/m6-board' - M6 board (threads, comments, markdown, tests) 2026-06-25 01:28:11 +02:00
2017585f84 feat(m6): board with threads/comments, markdown, tests, e2e
- BoardRepository (thread/comment CRUD, soft-delete, search, pagination,
  pinned-first ordering) + BoardService (membership permission,
  author/admin edit/delete, comment->board_commented notification,
  board_posted/comment_added activity logs, category validation)
- APIs: threads list/create/detail/edit/delete, comments create/edit/delete
- MarkdownBody (react-markdown + remark-gfm + rehype-sanitize),
  ThreadForm, CommentForm, board list + thread detail screens, ProjectNav link
- Unit tests (BoardRepository, BoardService) + e2e board.spec
2026-06-25 01:28:08 +02:00
ac598a50f2 Merge branch 'feature/m5-notifications-activity' - M5 notifications & activity log 2026-06-25 01:20:27 +02:00
3e02feb221 feat(m5): notifications & activity log foundation
- Expand NotificationRepository (unread list paginated, count, markRead
  ownership-scoped); new ActivityLogRepository (create, findByProject
  paginated) with deterministic created_at+id ordering
- NotificationService (notifyOnEvent, resolveTargets for 8 event types,
  injectable SseBroadcaster for M8) + ActivityLogService (logActivity,
  listByProject)
- NotificationEvent/SseEvent/SseBroadcaster types in lib/types
- APIs: GET /api/notifications, POST /api/notifications/:id/read,
  GET /api/projects/:projectId/activity
- Screens: notifications list + MarkReadButton, NotificationBadge in
  Header, project activity page + ProjectNav link
- Unit tests for both repositories and services (28 new)
2026-06-25 01:20:24 +02:00
95722e99f1 Merge branch 'fix/post-m4-validation' - post-M4 validation fixes 2026-06-25 01:11:38 +02:00
3dc0318011 fix: post-M4 validation improvements
- Session: verify server-side iat expiry (not just cookie maxAge)
- Validate ProjectMemberRole on member add (reject arbitrary strings)
- Scope coverage threshold to Repository/Service layers (per guidelines)
- Add session expiry/fresh-token unit tests
2026-06-25 01:11:35 +02:00
abef2c58d9 Merge branch 'feature/m4-project-member' - M4 project & member management (repos, service, API, screens, tests) 2026-06-25 01:02:20 +02:00
9eb391ea8d feat(m4): project & member management with tests and e2e
- ProjectRepository, ProjectMemberRepository, minimal NotificationRepository
- ProjectService: create/update/archive/delete project, add/remove member,
  permission checks (isMember/admin), member-added notification, getDashboard
  skeleton, getMyProjects, getMemberRole
- projectValidator, API services factory
- API routes: projects CRUD, members list/add/remove
- Screens: dashboard (project list + create form), project overview, members,
  settings; layout (Header/Sidebar/ProjectNav) and project components
- Unit tests (Project/ProjectMember repositories, ProjectService),
  integration project-member-permission, e2e project-management
2026-06-25 01:02:10 +02:00
8a67523c0e Merge branch 'feature/m3-auth-user' - M3 auth & user management (session, AuthService, API, screens, tests) 2026-06-25 00:36:50 +02:00
21b9f03e9d feat(m3): auth & user management with session, tests, and e2e
- Custom error classes (lib/errors.ts) and signed-cookie session
  (lib/auth/session.ts, HMAC-SHA256, httpOnly+sameSite=lax)
- UserRepository + AuthService (bcrypt hashing, role/status checks,
  register/login/logout/getCurrentUser/updateProfile)
- Auth helpers (getCurrentUser), user validator, API error handler
- API routes: register (auto-login), login, logout, me, PATCH users/me,
  GET admin/migrations (admin-only guard)
- middleware.ts redirects unauthenticated page access to /login
- Screens: login (login/register toggle), profile, dashboard skeleton,
  home redirect
- vitest: exclude e2e specs, setupFiles for SESSION_SECRET
- playwright: run migrate before dev server, set SESSION_SECRET
- Unit tests (UserRepository, AuthService, session), integration
  auth-flow, e2e auth.spec (register/login/logout/protected/401)
2026-06-25 00:36:42 +02:00
40ff4fb909 Merge branch 'feature/m2-db-foundation' - M2 DB foundation (SQL wrapper, migrator, schema, tests) 2026-06-25 00:21:37 +02:00
d7c4cd5e58 feat(m2): DB foundation tests + multi-statement migration fix
- Add SqliteDatabase.exec() using better-sqlite3 exec() so the migrator
  can run multi-statement SQL files (prepare() rejected 001_initial.sql)
- Migrator now uses exec() for migration file content
- Fix tests/helpers/db.ts: replace CJS require() with ESM import
- Add unit tests for SqliteDatabase (query/get/execute/transaction/exec,
  WAL & foreign_keys pragmas) and Migrator (ordering, idempotency,
  rollback, real 001_initial.sql applies)
- Restore .gitignore steering exclusion; remove stale steering files
2026-06-25 00:21:26 +02:00
197 changed files with 18209 additions and 828 deletions

3
.gitignore vendored
View File

@ -47,6 +47,9 @@ Thumbs.db
coverage/ coverage/
.nyc_output/ .nyc_output/
# Steering files (task management - temporary)
.steering/*
!.steering/.gitkeep
# Lock files (keep package-lock.json for consistency) # Lock files (keep package-lock.json for consistency)
# yarn.lock # yarn.lock

View File

@ -1,189 +0,0 @@
# Design Document
## Architecture Overview
Layered architecture (UI → Service → Repository → Data) per `docs/architecture.md`. Next.js 15 App Router with Node.js Runtime only (no Edge Runtime). SQLite via better-sqlite3 accessed exclusively through a custom SQL wrapper. No Prisma.
```
app/ (UI: Route Handlers / Server Components / SSE)
services/ (business logic, permission checks, transactions)
repositories/ (SQL, parameter binding, logical-delete filter)
lib/db/ (SQLite connection, SQL wrapper, Migration)
```
## Component Design
### 1. SQL Wrapper: `lib/db/sqlite.ts`
**Responsibilities**:
- SQLite connection management (singleton getDb(), WAL, foreign_keys ON)
- query<T> (multi-row), get<T> (single-row), execute (insert/update/delete), transaction<T>, close
- Error handling
**Implementation highlights**:
- dbPath = process.env.SQLITE_PATH ?? "./data/app.db"
- SqlParams = Record<string, unknown> | unknown[]
- All repositories use this wrapper; direct better-sqlite3 access forbidden
### 2. Migrator: `lib/db/migrator.ts`
**Responsibilities**:
- Create schema_migrations table
- Read .sql files from migrations dir in filename order
- Skip already-applied files (tracked in schema_migrations)
- Execute each file in a transaction; rollback on failure
### 3. Initial Schema: `lib/db/migrations/001_initial.sql`
16 tables: users, projects, project_members, board_threads, board_comments, chat_messages, todo_columns, todo_items, file_assets, project_notes, milestones, calendar_events, meetings, meeting_members, notifications, activity_logs + indexes.
### 4. Entity Types: `lib/types/`
PascalCase type files for each entity + union types (UserRole, UserStatus, ProjectStatus, ProjectMemberRole, etc.).
### 5. UserRepository
findById, findByEmail, create (with passwordHash), update (name/avatarUrl/role/status).
### 6. AuthService
register (hash password, create user), login (verify hash, issue session), logout, getCurrentUser, updateProfile. Uses bcrypt.
### 7. Session: `lib/auth/session.ts`, `lib/auth/getCurrentUser.ts`
Cookie-based session. getCurrentUser resolves the user from the request cookie.
### 8. ProjectRepository / ProjectMemberRepository
Project: findById, findByOwner, findByUser (via join), create, update, delete.
ProjectMember: findByProject, findByUser, add, remove, isMember, getRole.
### 9. ProjectService
createProject (owner becomes admin member), updateProject, addMember (permission check + notification hook later), removeMember, archiveProject, getDashboard skeleton.
## Data Flow
### Login
1. POST /api/auth/login → AuthService.login(email, password)
2. AuthService → UserRepository.findByEmail → verify bcrypt hash
3. Issue session cookie → return user
### Project creation
1. POST /api/projects → ProjectService.createProject(actorId, input)
2. ProjectService → permission (any authenticated user) → ProjectRepository.create
3. ProjectMemberRepository.add(projectId, ownerId, 'admin')
4. Return project
## Error Handling Strategy
### Custom Error Classes
- ValidationError (400) - field, value
- ForbiddenError (403)
- NotFoundError (404) - resource, id
- (409 for unique constraint - handled via SQLite error code)
### Error Handling Patterns
Route Handlers catch errors and map to HTTP status. Expected errors use custom classes; unexpected errors propagate + log.
## Test Strategy
### Unit Tests (Vitest)
- lib/db/sqlite.test.ts (query/get/execute/transaction, WAL, foreign keys)
- lib/db/migrator.test.ts (order, skip applied, rollback)
- repositories/UserRepository.test.ts
- services/AuthService.test.ts
- repositories/ProjectRepository.test.ts
- repositories/ProjectMemberRepository.test.ts
- services/ProjectService.test.ts
Tests use a temp SQLite file (in-memory or tmpdir) to avoid touching real data/.
### Integration Tests (Vitest)
- project-member-permission.test.ts (non-member cannot access project data)
### E2E Tests (Playwright)
- auth.spec.ts (login, logout, protected screen)
- project-management.spec.ts (create, edit, member add/remove, archive)
## Dependent Libraries
```json
{
"dependencies": {
"next": "15",
"react": "19",
"react-dom": "19",
"better-sqlite3": "^11",
"bcrypt": "^5",
"react-markdown": "^9",
"remark-gfm": "^4",
"rehype-sanitize": "^6"
},
"devDependencies": {
"tailwindcss": "^3",
"@types/better-sqlite3": "^7",
"@types/bcrypt": "^5",
"@playwright/test": "^1",
"tsx": "^4"
}
}
```
## Directory Structure
```
app/
api/auth/{register,login,logout,me}/route.ts
api/users/me/route.ts
api/projects/route.ts
api/projects/[projectId]/route.ts
api/projects/[projectId]/members/route.ts
api/projects/[projectId]/members/[userId]/route.ts
api/admin/migrations/route.ts
login/page.tsx
profile/page.tsx
dashboard/page.tsx
projects/[projectId]/{page,members,settings}.tsx
layout.tsx, globals.css
lib/
db/{sqlite.ts, migrator.ts, run-migrations.ts, migrations/001_initial.sql}
auth/{session.ts, getCurrentUser.ts}
types/*.ts
validators/{userValidator.ts, projectValidator.ts}
repositories/{UserRepository,ProjectRepository,ProjectMemberRepository}.ts
services/{AuthService,ProjectService}.ts
components/layout/{Header,Sidebar,ProjectNav}.tsx
tests/unit/..., tests/integration/..., tests/e2e/...
```
## Implementation Order
1. M1 (branch feature/m1-setup): Next.js setup, deps, config, dir structure, remove boilerplate → merge to main
2. M2 (branch feature/m2-db-foundation): SQL wrapper, migrator, schema, types, migration API, unit tests → merge to main
3. M3 (branch feature/m3-auth-user): UserRepository, AuthService, session, validators, auth API, login/profile pages, unit + e2e → merge to main
4. M4 (branch feature/m4-project-member): Project/Member repos, ProjectService, project API, dashboard/members/settings pages, unit + integration + e2e → merge to main
## Security Considerations
- Passwords hashed with bcrypt (never plaintext)
- All SQL parameter-bound (no string concatenation)
- Auth required on all protected routes (except /api/auth/register, /api/auth/login)
- Project access requires isMember check
- Admin features require role='system_admin'
- .env not committed; secrets via environment
## Performance Considerations
- SQLite WAL mode for read/write concurrency
- Pagination on list endpoints
- Singleton DB connection
## Future Extensibility
- Repository pattern allows adding tables without changing wrapper
- SseEvent type extensible (M8)
- NotificationService/ActivityLogService hooks prepared for M5 (ProjectService.addMember will call notification in M5)

View File

@ -1,102 +0,0 @@
# Requirements
## Overview
Implement milestones M1-M4 of the Simple Groupware project: project foundation setup (Next.js 15), DB foundation (SQL wrapper + Migration), authentication & user management, and project & member management. Each milestone is developed on a separate branch and merged to main. Unit tests and E2E tests are mandatory.
## Background
The repository is currently a spec-driven-development boilerplate (vitest/eslint/prettier/husky configured, but no Next.js, no react, no SQLite). It must be transformed into the foundation of the Simple Groupware: a Next.js 15 + TypeScript + SQLite(better-sqlite3) application following the layered architecture (UI → Service → Repository → Data) defined in `docs/architecture.md`. M1-M4 establish the base that all subsequent milestones (M5+) depend on.
## Features to Implement
### 1. M1: Project Foundation Setup
- Next.js 15 (App Router, TypeScript) project configuration
- Tailwind CSS setup
- tsconfig with `@/*` path alias, strict mode
- next.config, .env.example
- Directory structure: app/, lib/, repositories/, services/, components/, tests/, data/, backups/
- ESLint (with Next.js plugin), Prettier, Husky, lint-staged, Vitest, Playwright setup
- CI config (GitHub Actions)
- package.json scripts: lint, format, typecheck, test, test:e2e, migrate, dev, build
- Dependencies: next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize, tailwindcss, @types/better-sqlite3
- Remove boilerplate src/example.ts and src/example.test.ts
### 2. M2: DB Foundation (SQL Wrapper + Migration)
- `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON)
- `lib/db/migrator.ts`: Migrator class (filename-order execution, skip applied, 1-file-1-transaction, rollback on failure)
- `lib/db/migrations/001_initial.sql`: all 16 tables + indexes
- `lib/types/`: all Entity type definitions + enums
- `lib/db/run-migrations.ts`: migration runner script
- API: `GET /api/admin/migrations` (admin-only migration status)
- Unit tests: sqlite.test.ts, migrator.test.ts
### 3. M3: Authentication & User Management
- UserRepository (findById/findByEmail/create/update)
- AuthService (register/login/logout/getCurrentUser/updateProfile) with bcrypt password hashing
- Role management (system_admin/project_admin/member/guest), account enable/disable
- lib/auth/ (session.ts, getCurrentUser.ts)
- lib/validators/userValidator.ts
- API: register, login, logout, me, users/me
- Login page, profile page, root layout
- Auth middleware (redirect unauthenticated to login)
- Unit tests: UserRepository, AuthService; E2E test: auth.spec.ts
### 4. M4: Project & Member Management
- ProjectRepository, ProjectMemberRepository
- ProjectService (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard) with permission checks
- projectValidator.ts
- API: projects CRUD, members CRUD
- Dashboard (project list), project overview, members page, settings page
- Layout components (Header, Sidebar, ProjectNav)
- Unit tests: ProjectRepository, ProjectMemberRepository, ProjectService; Integration test: project-member-permission; E2E test: project-management.spec.ts
## Acceptance Criteria
### M1
- [ ] `npm run dev` starts the Next.js dev server
- [ ] `npm run lint`, `npm run typecheck`, `npm run build` succeed
- [ ] Directory structure matches repository-structure.md
### M2
- [ ] `npm run migrate` creates the initial schema
- [ ] Migrations run in filename order and skip applied ones
- [ ] Failed migration rolls back
- [ ] Unit tests pass
### M3
- [ ] User registration, login, logout work
- [ ] Password is hashed with bcrypt
- [ ] Profile & avatar editable
- [ ] Inactive accounts cannot log in
- [ ] Unauthenticated access to protected screens is blocked
- [ ] Unit & E2E tests pass
### M4
- [ ] Project create/edit/delete/archive work
- [ ] Member add/remove/role work
- [ ] Non-members get 403
- [ ] Unit, Integration & E2E tests pass
## Success Metrics
- All Unit tests pass (`npm test`)
- All E2E tests pass (`npm run test:e2e`)
- Lint and typecheck clean
- Each milestone merged to main on its own branch
## Out of Scope
The following will not be implemented in this phase:
- M5-M15 (notifications, board, chat, SSE, todo, files, calendar, milestones, meetings, search, dashboard completion, backup)
- E2E tests for features beyond M3/M4 scope (board, chat, etc.)
- Full project dashboard content (only skeleton in M4; full content in M13)
## Reference Documents
- `docs/product-requirements.md` - Product Requirements Document
- `docs/functional-design.md` - Functional design document
- `docs/architecture.md` - Architecture design document
- `docs/repository-structure.md` - Repository structure document
- `docs/development-guidelines.md` - Development guidelines
- `docs/milestones.md` - Milestone & task definitions

View File

@ -1,150 +0,0 @@
# Task List
## 🚨 Principle of Fully Completing Tasks
**Keep working until all tasks in this file are complete**
### Mandatory Rules
- **Make every task `[x]`**
- "Planned as a separate task due to time constraints" is forbidden
- "Postponed because the implementation is too complex" is forbidden
- Do not finish work while leaving incomplete tasks (`[ ]`)
### The Only Case Where Skipping a Task Is Permitted
Skipping is possible only when one of the following technical reasons applies:
- A change in the implementation approach made the feature itself unnecessary
- An architecture change replaced it with a different implementation method
- A change in dependencies made the task impossible to execute
When skipping, always state the reason clearly:
```markdown
- [x] ~~task name~~ (unnecessary due to a change in approach: specific technical reason)
```
### If a Task Is Too Large
- Split the task into smaller subtasks
- Add the split subtasks to this file
- Complete the subtasks one by one
---
## Phase 1: M1 - Project Foundation Setup (branch: feature/m1-setup)
- [x] Create branch `feature/m1-setup` from main
- [x] Update package.json: name, scripts (dev/build/lint/format/typecheck/test/test:e2e/migrate), dependencies (next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize), devDependencies (tailwindcss, @types/better-sqlite3, @types/bcrypt, @playwright/test, tsx, eslint-config-next)
- [x] Run `npm install` and confirm dependencies install successfully
- [x] Configure Next.js: create next.config.mjs (Node.js runtime), update tsconfig.json for Next.js (@/* path alias, jsx preserve, next plugin types), create app/globals.css (Tailwind directives), create tailwind.config.ts, postcss.config.mjs
- [x] Create Next.js app structure: app/layout.tsx (root layout), app/page.tsx (home redirect), app/globals.css
- [x] Create directory structure: lib/, repositories/, services/, components/, tests/unit, tests/integration, tests/e2e, data/.gitkeep, backups/.gitkeep
- [x] Remove boilerplate: delete src/example.ts, src/example.test.ts
- [x] Update vitest.config.ts (include tests/**, exclude .next), create playwright.config.ts
- [x] Update eslint.config.js (add Next.js plugin/ignores for .next), update .prettierrc if needed
- [x] Create .env.example (SQLITE_PATH, SESSION_SECRET)
- [x] Create CI config .github/workflows/ci.yml (lint, typecheck, test, build)
- [x] Commit M1, merge feature/m1-setup to main, push
## Phase 2: M2 - DB Foundation (branch: feature/m2-db-foundation)
- [ ] Create branch `feature/m2-db-foundation` from main
- [ ] Implement `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON)
- [ ] Implement `lib/db/migrator.ts`: Migrator class (schema_migrations table, filename-order, skip applied, 1-file-1-tx, rollback)
- [ ] Create `lib/db/migrations/001_initial.sql`: all 16 tables + indexes
- [ ] Create `lib/types/`: all Entity types + enums (User, Project, ProjectMember, BoardThread, BoardComment, ChatMessage, TodoColumn, TodoItem, FileAsset, ProjectNote, Milestone, CalendarEvent, Meeting, MeetingMember, Notification, ActivityLog, SchemaMigration + union types)
- [ ] Create `lib/db/run-migrations.ts`: migration runner script
- [ ] Implement API `GET /api/admin/migrations` (migration status, admin-only)
- [ ] Create test helper `tests/helpers/db.ts` (temp SQLite DB factory)
- [ ] Write Unit test `tests/unit/lib/db/sqlite.test.ts`
- [ ] Write Unit test `tests/unit/lib/db/migrator.test.ts`
- [ ] Run `npm run migrate` to verify schema creation
- [ ] Commit M2, merge feature/m2-db-foundation to main, push
## Phase 3: M3 - Auth & User Management (branch: feature/m3-auth-user)
- [ ] Create branch `feature/m3-auth-user` from main
- [ ] Create custom error classes `lib/errors.ts` (ValidationError, ForbiddenError, NotFoundError)
- [ ] Implement `lib/auth/session.ts` (session cookie read/write) and `lib/auth/getCurrentUser.ts`
- [ ] Implement `lib/validators/userValidator.ts`
- [ ] Implement `repositories/UserRepository.ts`
- [ ] Implement `services/AuthService.ts` (register/login/logout/getCurrentUser/updateProfile, bcrypt)
- [ ] Implement API routes: register, login, logout, me, users/me
- [ ] Implement auth middleware/guard (protected routes redirect to login)
- [ ] Implement `app/login/page.tsx`, `app/profile/page.tsx`, update `app/layout.tsx`
- [ ] Write Unit test `tests/unit/repositories/UserRepository.test.ts`
- [ ] Write Unit test `tests/unit/services/AuthService.test.ts`
- [ ] Write E2E test `tests/e2e/auth.spec.ts`
- [ ] Commit M3, merge feature/m3-auth-user to main, push
## Phase 4: M4 - Project & Member Management (branch: feature/m4-project-member)
- [ ] Create branch `feature/m4-project-member` from main
- [ ] Implement `lib/validators/projectValidator.ts`
- [ ] Implement `repositories/ProjectRepository.ts`
- [ ] Implement `repositories/ProjectMemberRepository.ts`
- [ ] Implement `services/ProjectService.ts` (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard skeleton, permission checks)
- [ ] Implement API routes: projects (list/create), projects/[projectId] (detail/edit/delete), members (list/add), members/[userId] (remove)
- [ ] Implement `app/dashboard/page.tsx` (project list skeleton)
- [ ] Implement `app/projects/[projectId]/page.tsx` (overview skeleton)
- [ ] Implement `app/projects/[projectId]/members/page.tsx`
- [ ] Implement `app/projects/[projectId]/settings/page.tsx`
- [ ] Implement layout components: components/layout/Header.tsx, Sidebar.tsx, ProjectNav.tsx
- [ ] Write Unit test `tests/unit/repositories/ProjectRepository.test.ts`
- [ ] Write Unit test `tests/unit/repositories/ProjectMemberRepository.test.ts`
- [ ] Write Unit test `tests/unit/services/ProjectService.test.ts`
- [ ] Write Integration test `tests/integration/project-member-permission.test.ts`
- [ ] Write E2E test `tests/e2e/project-management.spec.ts`
- [ ] Commit M4, merge feature/m4-project-member to main, push
## Phase 5: Quality Check and Fixes
- [ ] Confirm that all tests pass
- [ ] `npm test`
- [ ] Confirm that there are no lint errors
- [ ] `npm run lint`
- [ ] Confirm that there are no type errors
- [ ] `npm run typecheck`
- [ ] Confirm that the build succeeds
- [ ] `npm run build`
## Phase 6: Documentation Updates
- [ ] Update README.md (setup instructions, scripts)
- [ ] Post-implementation retrospective (record at the bottom of this file)
---
## Post-Implementation Retrospective
### Implementation Completion Date
{YYYY-MM-DD}
### Differences Between Plan and Actual
**Points that differed from the plan**:
- {Technical changes not anticipated at planning time}
- {Changes in the implementation approach and the reasons}
**Tasks that became newly necessary**:
- {Tasks added during implementation}
- {Why the addition was necessary}
**Tasks skipped for technical reasons** (only when applicable):
- {Task name}
- Reason for skipping: {specific technical reason}
- Alternative implementation: {what it was replaced with}
**⚠️ Note**: Do not list tasks skipped for reasons such as "time constraints" or "difficulty" here. Completing all tasks is the principle.
### Lessons Learned
**Technical insights**:
- {Technical knowledge gained through implementation}
- {New technologies or patterns used}
**Process improvements**:
- {What went well in task management}
- {How the steering files were leveraged}
### Improvement Suggestions for Next Time
- {Things to watch out for in the next feature addition}
- {More efficient implementation methods}
- {Improvements to task planning}

596
README.md
View File

@ -1,396 +1,288 @@
# opencode Spec-Driven Development Boilerplate # OpenGroupware
A spec-driven development boilerplate for [opencode](https://opencode.ai), converted from the A simple, self-contained, project-based groupware built with **Next.js 15**, **TypeScript**, and
[Claude Code boilerplate](https://git.yasue.org/ken/claudecode-boilerplate) that accompanies the book **SQLite**. It runs entirely on Node.js (no external database or storage) and provides boards,
*"Practical Claude Code Introduction."* 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 The codebase follows a strict **layered architecture**:
documents, per-task steering files, AI skills, subagents, and slash commands.
```
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 ## 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) - [Prerequisites](#prerequisites)
- [Quick Start](#quick-start) - [How to Start](#how-to-start)
- [Directory Structure](#directory-structure) - [How to Test](#how-to-test)
- [Configuration Overview](#configuration-overview) - [How to Develop](#how-to-develop)
- [Usage Workflow](#usage-workflow) - [Environment Variables](#environment-variables)
- [Commands Reference](#commands-reference) - [Project Structure](#project-structure)
- [Skills Reference](#skills-reference) - [npm Scripts Reference](#npm-scripts-reference)
- [Agents Reference](#agents-reference)
- [Customizing the Boilerplate](#customizing-the-boilerplate)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [License](#license) - [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 ## Prerequisites
- **Node.js** v18+ (v24.11.0 recommended) - **Node.js** v24.11.0 (LTS; v18+ works) — the dev container pins this version
- **npm** - **npm** 11.x (bundled with Node.js)
- **opencode** — install with: - **Playwright browsers** for E2E tests (see [How to Test](#how-to-test))
```bash
curl -fsSL https://opencode.ai/install | bash A Dev Container configuration (`.devcontainer/`) is included for VS Code.
```
See <https://opencode.ai/docs> for alternatives (npm, Homebrew, etc.)
- **Docker** (only if using the Dev Container)
--- ---
## Quick Start ## How to 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
```bash ```bash
# 1. Install dependencies # 1. Install dependencies
npm install npm install
# 2. Set up Git hooks # 2. Create your local environment file
npm run prepare 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 # 3. Initialize the database (runs all SQL migrations)
opencode 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/<name>` (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 <name>` 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) ├── app/ # Next.js App Router: pages + Route Handlers (all runtime='nodejs')
├── AGENTS.md # Project instructions (loaded into every session) │ ├── api/ # REST/SSE endpoints
├── package.json # Node.js project + scripts ├── projects/[projectId]/ # project screens (board, notes, chat, todos, files, calendar, ...)
├── tsconfig.json # TypeScript config │ ├── admin/backups/ # admin-only backup screen
├── eslint.config.js # ESLint flat config │ └── login / profile / dashboard / notifications
├── vitest.config.ts # Vitest config (80% coverage threshold) ├── lib/
├── .prettierrc / .prettierignore │ ├── db/ # SqliteDatabase, Migrator, migrations/*.sql
├── .gitignore │ ├── sse/ # SseHub
├── .husky/pre-commit # Runs lint-staged + typecheck before commit │ ├── auth/ # session, getCurrentUser
├── .devcontainer/devcontainer.json │ ├── types/ # entity & event types
├── prompt.md # Example MVP prompt │ ├── validators/ # input validators
├── api/ # service factories + error handling for route handlers
├── src/ # Source code │ └── errors.ts # AppError hierarchy (ValidationError, ForbiddenError, ...)
│ ├── example.ts ├── repositories/ # one class per table (SQL + mapping)
│ └── example.test.ts ├── services/ # business logic + permission checks + side effects
├── components/ # React components by area (layout, board, chat, todo, ...)
├── docs/ # Persistent design documents (created by /setup-project) ├── tests/
── ideas/ ── unit/ # Vitest — mirrors source structure
└── initial-requirements.md # Brainstorming notes (pre-PRD) ├── integration/ # Vitest — real temp SQLite DB
├── e2e/ # Playwright specs + globalSetup.ts
├── .steering/ # Per-task steering files (created by /add-feature) │ └── helpers/db.ts # createTestDb() / createMigratedTestDb()
│ └── .gitkeep ├── data/ # git-ignored: app.db, uploads/
├── backups/ # git-ignored: backup-<timestamp>.zip
── .opencode/ # opencode configuration ── docs/ # persistent design docs (PRD, architecture, milestones, ...)
├── agent/ # Subagent definitions └── .steering/ # per-task plans (git-ignored working artifacts)
│ ├── 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/)
``` ```
--- ---
## Configuration Overview ## npm Scripts Reference
### `opencode.json` | Script | Description |
|---|---|
The main configuration file. Key sections: | `npm run dev` | Start the Next.js dev server (http://localhost:3000) |
| `npm run build` | Production build |
```jsonc | `npm run start` | Serve the production build |
{ | `npm run lint` | ESLint |
"$schema": "https://opencode.ai/config.json", | `npm run format` | Prettier (write) |
"instructions": ["AGENTS.md"], // Loaded into every session | `npm run typecheck` | `tsc --noEmit` |
"permission": { | `npm test` | Run unit/integration tests (Vitest, once) |
"skill": "allow", // Skills are auto-available | `npm run test:watch` | Vitest watch mode |
"bash": { // Common dev commands pre-approved | `npm run test:coverage` | Vitest with coverage (80% threshold on repos+services) |
"*": "ask", | `npm run test:e2e` | Playwright E2E (headless, full suite) |
"npm *": "allow", | `npm run test:e2e:ui` | Playwright interactive UI mode (screenshots + traces) |
"npx *": "allow", | `npm run migrate` | Apply SQL migrations |
"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 <https://opencode.ai/config.json> 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
--- ---
## Troubleshooting ## Troubleshooting
### opencode won't start after editing config - **App won't start: `SESSION_SECRET is not configured`** — create `.env` from `.env.example` and
set `SESSION_SECRET`.
opencode validates `opencode.json` strictly and refuses to start on invalid fields. To recover: - **`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
```bash `prepare()`.
# Skip the project config and start from globals only - **E2E: `Executable doesn't exist`** — run `npx playwright install chromium`.
OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode - **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.
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
```
--- ---

View File

@ -0,0 +1,27 @@
import path from 'node:path';
import { NextResponse } from 'next/server';
import { Migrator } from '@/lib/db/migrator';
import { getDb } from '@/lib/db/sqlite';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError, ForbiddenError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
if (user.role !== 'system_admin') {
return handleApiError(new ForbiddenError('管理者のみアクセス可能です'));
}
try {
const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations');
const migrator = new Migrator(getDb(), migrationsDir);
return NextResponse.json({ migrations: migrator.getAppliedMigrations() });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/services/AuthService';
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { toPublicUser } from '@/lib/auth/getCurrentUser';
import { setSessionCookie } from '@/lib/auth/session';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const authService = new AuthService(new UserRepository(getDb()));
try {
const { user, token } = authService.login(
String(body.email ?? ''),
String(body.password ?? '')
);
const response = NextResponse.json(
{ user: toPublicUser(user) },
{ status: 200 }
);
setSessionCookie(response, token);
return response;
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { clearSessionCookie } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST() {
const response = NextResponse.json({ ok: true });
clearSessionCookie(response);
return response;
}

14
app/api/auth/me/route.ts Normal file
View File

@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
return NextResponse.json({ user: toPublicUser(user) });
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/services/AuthService';
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { toPublicUser } from '@/lib/auth/getCurrentUser';
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const authService = new AuthService(new UserRepository(getDb()));
try {
const user = authService.register({
name: String(body.name ?? ''),
email: String(body.email ?? ''),
password: String(body.password ?? ''),
});
// 登録成功と同時にログイン(セッションCookieを設定)
const response = NextResponse.json(
{ user: toPublicUser(user) },
{ status: 201 }
);
setSessionCookie(response, createSessionToken(user.id));
return response;
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createDashboardService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const service = createDashboardService();
try {
return NextResponse.json(service.getPersonalDashboard(user.id));
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'node:fs';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { fileId } = await params;
const service = createFileStorageService();
try {
const file = service.getFileInfo(user.id, Number(fileId));
const body = fs.readFileSync(file.path);
return new NextResponse(body, {
headers: {
'Content-Type': file.mimeType,
'Content-Disposition': `inline; filename="${file.originalName}"`,
'Content-Length': String(file.size),
},
});
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { fileId } = await params;
const service = createFileStorageService();
try {
service.delete(user.id, Number(fileId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createNotificationService } from '@/lib/api/services';
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { id } = await params;
const service = createNotificationService();
const updated = service.markRead(Number(id), user.id);
if (!updated) {
return handleApiError(new NotFoundError('Notification', id));
}
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createNotificationService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const service = createNotificationService();
const result = service.listUnread(user.id, page);
return NextResponse.json(result);
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import {
createProjectService,
createActivityLogService,
} from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId } = await params;
// メンバーシップ確認(非参加者は403)
const projectService = createProjectService();
try {
projectService.getProject(user.id, Number(projectId));
} catch (error) {
return handleApiError(error);
}
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const activityService = createActivityLogService();
const result = activityService.listByProject(Number(projectId), page);
return NextResponse.json(result);
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createBoardService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; commentId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { commentId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createBoardService();
try {
const comment = service.updateComment(
user.id,
Number(commentId),
String(body.bodyMd ?? '')
);
return NextResponse.json({ comment });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; commentId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { commentId } = await params;
const service = createBoardService();
try {
service.deleteComment(user.id, Number(commentId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createBoardService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { threadId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createBoardService();
try {
const comment = service.createComment(
user.id,
Number(threadId),
String(body.bodyMd ?? '')
);
return NextResponse.json({ comment }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createBoardService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { threadId } = await params;
const service = createBoardService();
try {
const thread = service.getThread(user.id, Number(threadId));
const comments = service.listComments(user.id, Number(threadId));
return NextResponse.json({ thread, comments });
} catch (error) {
return handleApiError(error);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { threadId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createBoardService();
try {
const thread = service.updateThread(user.id, Number(threadId), {
title: typeof body.title === 'string' ? body.title : undefined,
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
category:
typeof body.category === 'string'
? (body.category as never)
: undefined,
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
isImportant:
typeof body.isImportant === 'number' ? body.isImportant : undefined,
});
return NextResponse.json({ thread });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; threadId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { threadId } = await params;
const service = createBoardService();
try {
service.deleteThread(user.id, Number(threadId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createBoardService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const search = request.nextUrl.searchParams.get('q') ?? undefined;
const service = createBoardService();
try {
return NextResponse.json(
service.listThreads(user.id, Number(projectId), { page, search })
);
} catch (error) {
return handleApiError(error);
}
}
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 service = createBoardService();
try {
const thread = service.createThread(user.id, Number(projectId), {
title: String(body.title ?? ''),
bodyMd: String(body.bodyMd ?? ''),
category:
typeof body.category === 'string'
? (body.category as never)
: undefined,
});
return NextResponse.json({ thread }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createScheduleService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { eventId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createScheduleService();
try {
const event = service.updateEvent(user.id, Number(eventId), {
title: typeof body.title === 'string' ? body.title : undefined,
description:
typeof body.description === 'string' ? body.description : undefined,
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
endAt:
typeof body.endAt === 'string'
? body.endAt
: body.endAt === null
? null
: undefined,
});
return NextResponse.json({ event });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { eventId } = await params;
const service = createScheduleService();
try {
service.deleteEvent(user.id, Number(eventId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createScheduleService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const from = request.nextUrl.searchParams.get('from') ?? '';
const to = request.nextUrl.searchParams.get('to') ?? '';
const service = createScheduleService();
try {
return NextResponse.json(
service.getCalendarEvents(user.id, Number(projectId), { from, to })
);
} catch (error) {
return handleApiError(error);
}
}
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 service = createScheduleService();
try {
const event = service.createEvent(user.id, {
projectId: Number(projectId),
title: String(body.title ?? ''),
type: (typeof body.type === 'string' ? body.type : 'custom') as never,
startAt: String(body.startAt ?? ''),
endAt: typeof body.endAt === 'string' ? body.endAt : null,
description:
typeof body.description === 'string' ? body.description : null,
});
return NextResponse.json({ event }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createChatService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; messageId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { messageId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createChatService();
try {
const message = service.editMessage(
user.id,
Number(messageId),
String(body.body ?? '')
);
return NextResponse.json({ message });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; messageId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { messageId } = await params;
const service = createChatService();
try {
service.deleteMessage(user.id, Number(messageId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createChatService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const search = request.nextUrl.searchParams.get('q') ?? undefined;
const service = createChatService();
try {
return NextResponse.json(
service.getHistory(user.id, Number(projectId), { page, search })
);
} catch (error) {
return handleApiError(error);
}
}
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 service = createChatService();
try {
const message = service.sendMessage(
user.id,
Number(projectId),
String(body.body ?? '')
);
return NextResponse.json({ message }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,70 @@
import { NextRequest } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { getSseHub } from '@/lib/sse/hub';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const projectService = createProjectService();
try {
projectService.getProject(user.id, Number(projectId));
} catch (error) {
return handleApiError(error);
}
const pid = Number(projectId);
const hub = getSseHub();
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const client = {
enqueue: (chunk: string) => controller.enqueue(encoder.encode(chunk)),
close: () => {
try {
controller.close();
} catch {
// 既に閉じられている
}
},
};
hub.addClient(pid, client);
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': ping\n\n'));
} catch {
clearInterval(heartbeat);
}
}, 30_000);
request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
hub.removeClient(pid, client);
try {
controller.close();
} catch {
// 既に閉じられている
}
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}

View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError, ValidationError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const service = createFileStorageService();
try {
return NextResponse.json(
service.listFiles(user.id, Number(projectId), page)
);
} catch (error) {
return handleApiError(error);
}
}
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;
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return handleApiError(
new ValidationError('ファイルを指定してください', 'file')
);
}
const data = Buffer.from(await file.arrayBuffer());
const service = createFileStorageService();
try {
const fileAsset = service.upload(user.id, Number(projectId), {
originalName: file.name,
mimeType: file.type || 'application/octet-stream',
data,
});
return NextResponse.json({ file: fileAsset }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createMeetingService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; id: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { id } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createMeetingService();
try {
if (body.minutesMd !== undefined) {
const meeting = service.updateMinutes(
user.id,
Number(id),
String(body.minutesMd ?? '')
);
return NextResponse.json({ meeting });
}
const meeting = service.updateMeeting(user.id, Number(id), {
title: typeof body.title === 'string' ? body.title : undefined,
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
endAt: typeof body.endAt === 'string' ? body.endAt : undefined,
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : undefined,
description:
typeof body.description === 'string' ? body.description : undefined,
});
return NextResponse.json({ meeting });
} catch (error) {
return handleApiError(error);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; id: string }> }
) {
// /api/projects/:projectId/meetings/check は専用ルートで処理するが、
// ここでは meetingId='check' の場合は重複チェックのみを行う
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId, id } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createMeetingService();
try {
const conflicts = service.checkScheduleConflicts(
user.id,
Number(projectId),
Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [],
String(body.startAt ?? ''),
String(body.endAt ?? ''),
id === 'check' ? undefined : Number(id)
);
return NextResponse.json({ conflicts });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createMeetingService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const service = createMeetingService();
try {
return NextResponse.json({
meetings: service.getMeetings(user.id, Number(projectId)),
});
} catch (error) {
return handleApiError(error);
}
}
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 service = createMeetingService();
try {
const result = service.createMeeting(user.id, Number(projectId), {
title: String(body.title ?? ''),
startAt: String(body.startAt ?? ''),
endAt: String(body.endAt ?? ''),
description:
typeof body.description === 'string' ? body.description : null,
location: typeof body.location === 'string' ? body.location : null,
meetingUrl: typeof body.meetingUrl === 'string' ? body.meetingUrl : null,
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : null,
memberIds: Array.isArray(body.memberIds)
? body.memberIds.map(Number)
: [],
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function DELETE(
_request: NextRequest,
{
params,
}: {
params: Promise<{ projectId: string; userId: string }>;
}
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId, userId } = await params;
const service = createProjectService();
try {
service.removeMember(user.id, Number(projectId), Number(userId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService, createUserRepository } from '@/lib/api/services';
import { validateProjectMemberRole } from '@/lib/validators/projectValidator';
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId } = await params;
const service = createProjectService();
try {
const members = service.getMembers(user.id, Number(projectId));
return NextResponse.json({ members });
} catch (error) {
return handleApiError(error);
}
}
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 email = String(body.email ?? '');
const role = validateProjectMemberRole(
typeof body.role === 'string' ? body.role : 'member'
);
// メールアドレスからユーザーを解決する
const targetUser = createUserRepository().findByEmail(email);
if (!targetUser) {
return handleApiError(new NotFoundError('User', email));
}
const service = createProjectService();
try {
const member = service.addMember(
user.id,
Number(projectId),
targetUser.id,
role
);
return NextResponse.json({ member }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createScheduleService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; id: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { id } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createScheduleService();
try {
const milestone = service.updateMilestone(user.id, Number(id), {
title: typeof body.title === 'string' ? body.title : undefined,
dueDate:
typeof body.dueDate === 'string'
? body.dueDate
: body.dueDate === null
? null
: undefined,
status:
typeof body.status === 'string'
? (body.status as 'open' | 'closed')
: undefined,
description:
typeof body.description === 'string' ? body.description : undefined,
});
return NextResponse.json({ milestone });
} catch (error) {
return handleApiError(error);
}
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; id: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { id } = await params;
const service = createScheduleService();
try {
return NextResponse.json({
progress: service.getMilestoneProgress(user.id, Number(id)),
});
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createScheduleService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const service = createScheduleService();
try {
return NextResponse.json({
milestones: service.getMilestones(user.id, Number(projectId)),
});
} catch (error) {
return handleApiError(error);
}
}
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 service = createScheduleService();
try {
const milestone = service.createMilestone(user.id, Number(projectId), {
title: String(body.title ?? ''),
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
description:
typeof body.description === 'string' ? body.description : null,
});
return NextResponse.json({ milestone }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createNoteService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { noteId } = await params;
const service = createNoteService();
try {
const note = service.getNote(user.id, Number(noteId));
return NextResponse.json({ note });
} catch (error) {
return handleApiError(error);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { noteId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createNoteService();
try {
const note = service.updateNote(user.id, Number(noteId), {
title: typeof body.title === 'string' ? body.title : undefined,
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
tags: typeof body.tags === 'string' ? body.tags : undefined,
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
});
return NextResponse.json({ note });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { noteId } = await params;
const service = createNoteService();
try {
service.deleteNote(user.id, Number(noteId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createNoteService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const search = request.nextUrl.searchParams.get('q') ?? undefined;
const service = createNoteService();
try {
return NextResponse.json(
service.listNotes(user.id, Number(projectId), { page, search })
);
} catch (error) {
return handleApiError(error);
}
}
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 service = createNoteService();
try {
const note = service.createNote(user.id, Number(projectId), {
title: String(body.title ?? ''),
bodyMd: String(body.bodyMd ?? ''),
tags: typeof body.tags === 'string' ? body.tags : undefined,
});
return NextResponse.json({ note }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId } = await params;
const service = createProjectService();
try {
const dashboard = service.getDashboard(user.id, Number(projectId));
return NextResponse.json(dashboard);
} catch (error) {
return handleApiError(error);
}
}
export async function PATCH(
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 service = createProjectService();
try {
const project = service.updateProject(user.id, Number(projectId), {
name: typeof body.name === 'string' ? body.name : undefined,
description:
typeof body.description === 'string' ? body.description : undefined,
status:
typeof body.status === 'string'
? (body.status as 'active' | 'on_hold' | 'completed' | 'archived')
: undefined,
});
return NextResponse.json({ project });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId } = await params;
const service = createProjectService();
try {
service.deleteProject(user.id, Number(projectId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createSearchService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const q = request.nextUrl.searchParams.get('q') ?? '';
const type = (request.nextUrl.searchParams.get('type') ?? undefined) as
| string
| undefined;
const service = createSearchService();
try {
return NextResponse.json(
service.search(user.id, Number(projectId), {
q,
type: type as never,
})
);
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,49 @@
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';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; columnId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { columnId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createTodoService();
try {
const column = service.updateColumn(user.id, Number(columnId), {
name: typeof body.name === 'string' ? body.name : undefined,
orderIndex:
typeof body.orderIndex === 'number' ? body.orderIndex : undefined,
});
return NextResponse.json({ column });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; columnId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { columnId } = await params;
const service = createTodoService();
try {
service.deleteColumn(user.id, Number(columnId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,51 @@
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';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const service = createTodoService();
try {
return NextResponse.json({
columns: service.getColumns(user.id, Number(projectId)),
});
} catch (error) {
return handleApiError(error);
}
}
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 service = createTodoService();
try {
const column = service.createColumn(
user.id,
Number(projectId),
String(body.name ?? ''),
typeof body.orderIndex === 'number' ? body.orderIndex : undefined
);
return NextResponse.json({ column }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,76 @@
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';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { itemId } = await params;
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createTodoService();
try {
// complete は専用フラグでトグル、それ以外は通常更新
if (body.toggleComplete === true) {
const item = service.toggleComplete(user.id, Number(itemId));
return NextResponse.json({ item });
}
if (body.columnId !== undefined && body.orderIndex !== undefined) {
const item = service.moveItem(
user.id,
Number(itemId),
Number(body.columnId),
Number(body.orderIndex)
);
return NextResponse.json({ item });
}
const item = service.updateItem(user.id, Number(itemId), {
title: typeof body.title === 'string' ? body.title : undefined,
description:
typeof body.description === 'string' ? body.description : undefined,
assigneeId:
body.assigneeId === null || body.assigneeId === undefined
? undefined
: Number(body.assigneeId),
priority:
typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high')
: undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined,
milestoneId:
body.milestoneId === null || body.milestoneId === undefined
? undefined
: Number(body.milestoneId),
});
return NextResponse.json({ item });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { itemId } = await params;
const service = createTodoService();
try {
service.deleteItem(user.id, Number(itemId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,60 @@
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';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const service = createTodoService();
try {
return NextResponse.json({
items: service.getItems(user.id, Number(projectId)),
});
} catch (error) {
return handleApiError(error);
}
}
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 service = createTodoService();
try {
const item = service.createItem(user.id, Number(projectId), {
title: String(body.title ?? ''),
columnId: Number(body.columnId ?? 0),
description:
typeof body.description === 'string' ? body.description : undefined,
assigneeId:
body.assigneeId === null || body.assigneeId === undefined
? null
: Number(body.assigneeId),
priority:
typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high')
: undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
});
return NextResponse.json({ item }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

43
app/api/projects/route.ts Normal file
View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const service = createProjectService();
const projects = service.getMyProjects(user.id);
return NextResponse.json({ projects });
}
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createProjectService();
try {
const project = service.createProject(user.id, {
name: String(body.name ?? ''),
description:
typeof body.description === 'string' ? body.description : undefined,
});
return NextResponse.json({ project }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

36
app/api/users/me/route.ts Normal file
View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/services/AuthService';
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(request: NextRequest) {
const currentUser = await getCurrentUser();
if (!currentUser) {
return handleApiError(new UnauthorizedError());
}
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const authService = new AuthService(new UserRepository(getDb()));
try {
const updated = authService.updateProfile(currentUser.id, {
name: typeof body.name === 'string' ? body.name : undefined,
email: typeof body.email === 'string' ? body.email : undefined,
avatarUrl:
typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined,
});
return NextResponse.json({ user: toPublicUser(updated) });
} catch (error) {
return handleApiError(error);
}
}

88
app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,88 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createDashboardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
import { DashboardWidget } from '@/components/project/DashboardWidget';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const service = createDashboardService();
const dashboard = service.getPersonalDashboard(user.id);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<div className="grid gap-4 sm:grid-cols-2">
<DashboardWidget title="参加プロジェクト" empty="ありません">
{dashboard.projects.map((p) => (
<a
key={p.id}
href={`/projects/${p.id}`}
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50"
>
{p.name}{p.status}
</a>
))}
</DashboardWidget>
<DashboardWidget
title={`未読通知 (${dashboard.unreadNotificationCount})`}
empty="未読はありません"
>
<a
href="/notifications"
className="text-sm text-blue-600 hover:underline"
>
</a>
</DashboardWidget>
<DashboardWidget title="未完了ToDo" empty="ありません">
{dashboard.incompleteTodos.map((t) => (
<p key={t.id} className="text-sm">
{t.title}
{t.dueDate ? `(期限: ${t.dueDate}` : ''}
</p>
))}
</DashboardWidget>
<DashboardWidget title="期限切れタスク" empty="ありません">
{dashboard.overdueTasks.map((t) => (
<p key={t.id} className="text-sm text-red-600">
{t.title}: {t.dueDate}
</p>
))}
</DashboardWidget>
<DashboardWidget title="近日中のミーティング" empty="ありません">
{dashboard.upcomingMeetings.map((m) => (
<p key={m.id} className="text-sm">
{m.title}{m.startAt}
</p>
))}
</DashboardWidget>
<DashboardWidget title="最近のアクティビティ" empty="ありません">
{dashboard.recentActivity.map((l) => (
<p key={l.id} className="text-sm">
{l.action}{l.targetType}
</p>
))}
</DashboardWidget>
</div>
<CreateProjectForm />
</main>
</div>
);
}

131
app/login/page.tsx Normal file
View File

@ -0,0 +1,131 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
type Mode = 'login' | 'register';
export default function LoginPage() {
const router = useRouter();
const [mode, setMode] = useState<Mode>('login');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const endpoint =
mode === 'login' ? '/api/auth/login' : '/api/auth/register';
const payload =
mode === 'login' ? { email, password } : { name, email, password };
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
router.push('/dashboard');
return;
}
const data = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(data?.error?.message ?? '処理に失敗しました');
setLoading(false);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm">
<h1 className="text-2xl font-bold">
{mode === 'login' ? 'ログイン' : '新規登録'}
</h1>
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
{mode === 'register' && (
<div>
<label htmlFor="name" className="block text-sm font-medium">
</label>
<input
id="name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
</label>
<input
id="password"
name="password"
type="password"
autoComplete={
mode === 'login' ? 'current-password' : 'new-password'
}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
minLength={8}
/>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '処理中...' : mode === 'login' ? 'ログイン' : '登録する'}
</button>
</form>
<button
type="button"
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setError(null);
}}
>
{mode === 'login' ? '新規登録はこちら' : 'ログイン画面に戻る'}
</button>
</div>
</main>
);
}

View File

@ -0,0 +1,27 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createNotificationService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { NotificationList } from '@/components/notifications/NotificationList';
export const dynamic = 'force-dynamic';
export default async function NotificationsPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const service = createNotificationService();
const { items } = service.listUnread(user.id, 1);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<main className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<NotificationList notifications={items} />
</main>
</div>
);
}

View File

@ -1,10 +1,12 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Home() { export default function Home() {
return ( const router = useRouter();
<main className="flex min-h-screen flex-col items-center justify-center p-8"> useEffect(() => {
<h1 className="text-3xl font-bold"></h1> router.replace('/dashboard');
<p className="mt-4 text-gray-600"> }, [router]);
return null;
</p>
</main>
);
} }

146
app/profile/page.tsx Normal file
View File

@ -0,0 +1,146 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser';
export default function ProfilePage() {
const router = useRouter();
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch('/api/auth/me')
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
.then((data: { user: PublicUser }) => {
setUser(data.user);
setName(data.user.name);
setEmail(data.user.email);
setAvatarUrl(data.user.avatarUrl ?? '');
})
.catch(() => router.push('/login'))
.finally(() => setLoading(false));
}, [router]);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSaved(false);
const res = await fetch('/api/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, avatarUrl }),
});
if (res.ok) {
const data = (await res.json()) as { user: PublicUser };
setUser(data.user);
setSaved(true);
} else {
const data = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(data?.error?.message ?? '更新に失敗しました');
}
}
async function onLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
}
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center">
<p>...</p>
</main>
);
}
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<button
type="button"
onClick={onLogout}
className="text-sm text-blue-600 hover:underline"
>
</button>
</div>
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium">
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="avatarUrl" className="block text-sm font-medium">
URL
</label>
<input
id="avatarUrl"
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
{saved && (
<p className="text-sm text-green-600"></p>
)}
<button
type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
>
</button>
</form>
<button
type="button"
onClick={() => router.push('/dashboard')}
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
>
</button>
<p className="mt-2 text-center text-xs text-gray-500">
: {user?.role}
</p>
</div>
</main>
);
}

View File

@ -0,0 +1,81 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import {
createProjectService,
createActivityLogService,
} from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const ACTION_LABELS: Record<string, string> = {
todo_created: 'ToDo作成',
todo_updated: 'ToDo更新',
todo_completed: 'ToDo完了',
file_uploaded: 'ファイルアップロード',
board_posted: '掲示板投稿',
comment_added: 'コメント追加',
note_created: 'メモ作成',
note_updated: 'メモ更新',
meeting_created: 'ミーティング作成',
member_added: 'メンバー追加',
milestone_updated: 'マイルストーン更新',
};
export default async function ProjectActivityPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const projectService = createProjectService();
let project;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const activityService = createActivityLogService();
const { items } = activityService.listByProject(project.id, 1);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="activity" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
{items.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<ul className="divide-y rounded-lg border bg-white shadow-sm">
{items.map((log) => (
<li key={log.id} className="p-4">
<div className="flex items-center justify-between">
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{ACTION_LABELS[log.action] ?? log.action}
</span>
<span className="text-xs text-gray-400">{log.createdAt}</span>
</div>
<p className="mt-2 text-sm text-gray-700">
{log.targetType}
{log.targetId !== null ? ` #${log.targetId}` : ''}
</p>
</li>
))}
</ul>
)}
</main>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createBoardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { MarkdownBody } from '@/components/board/MarkdownBody';
import { CommentForm } from '@/components/board/CommentForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function ThreadDetailPage({
params,
}: {
params: Promise<{ projectId: string; threadId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId, threadId } = await params;
const boardService = createBoardService();
let thread;
let comments;
try {
thread = boardService.getThread(user.id, Number(threadId));
comments = boardService.listComments(user.id, Number(threadId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect(`/projects/${projectId}/board`);
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={Number(projectId)} active="board" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<a
href={`/projects/${projectId}/board`}
className="text-sm text-blue-600 hover:underline"
>
</a>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{thread.title}</h1>
{thread.category && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{thread.category}
</span>
)}
</div>
<div className="mt-4">
<MarkdownBody bodyMd={thread.bodyMd} />
</div>
<p className="mt-4 text-xs text-gray-400">
稿: {thread.createdAt} / : {thread.updatedAt}
</p>
</div>
<section className="space-y-4">
<h2 className="text-lg font-bold"> ({comments.total})</h2>
{comments.items.map((comment) => (
<div
key={comment.id}
className="rounded-lg border bg-white p-4 shadow-sm"
>
<MarkdownBody bodyMd={comment.bodyMd} />
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
</div>
))}
<CommentForm projectId={Number(projectId)} threadId={thread.id} />
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,77 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createBoardService, createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { ThreadForm } from '@/components/board/ThreadForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function BoardPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const { q, page } = await searchParams;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const boardService = createBoardService();
const { items } = boardService.listThreads(user.id, project.id, {
page: Number(page ?? '1') || 1,
search: q,
});
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="board" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<ThreadForm projectId={project.id} />
<section className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
items.map((thread) => (
<a
key={thread.id}
href={`/projects/${project.id}/board/${thread.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">
{thread.isPinned === 1 && '📌 '}
{thread.isImportant === 1 && '❗ '}
{thread.title}
</h3>
{thread.category && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{thread.category}
</span>
)}
</div>
<p className="mt-1 text-xs text-gray-400">{thread.createdAt}</p>
</a>
))
)}
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,82 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import {
createScheduleService,
createProjectService,
} from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { CalendarEventForm } from '@/components/calendar/CalendarEventForm';
import { CalendarView } from '@/components/calendar/CalendarView';
import {
rangeForView,
toISODate,
parseISODate,
type CalendarViewMode,
} from '@/lib/calendar/grid';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const VALID_VIEWS: CalendarViewMode[] = ['month', 'week', 'day'];
function isCalendarView(v: unknown): v is CalendarViewMode {
return typeof v === 'string' && (VALID_VIEWS as string[]).includes(v);
}
export default async function CalendarPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ view?: string; date?: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const { view, date } = await searchParams;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
// 表示モードと基準日を解決(不正値はデフォルトへフォールバック)
const resolvedView: CalendarViewMode = isCalendarView(view) ? view : 'month';
const today = new Date();
const anchor =
date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? parseISODate(date) : today;
// ビューに応じた取得範囲を計算しイベントを取得。
// アクセス権は getProject で参加確認済みなのでここでは再チェックしない。
const range = rangeForView(resolvedView, anchor);
const scheduleService = createScheduleService();
const events = scheduleService.getCalendarEvents(user.id, project.id, range);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="calendar" />
<main className="mx-auto max-w-6xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<CalendarEventForm
projectId={project.id}
defaultDate={toISODate(anchor)}
/>
<CalendarView
projectId={project.id}
events={events}
view={resolvedView}
anchorDate={toISODate(anchor)}
todayKey={toISODate(today)}
/>
</main>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { ChatWindow } from '@/components/chat/ChatWindow';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function ChatPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="chat" />
<main className="mx-auto max-w-3xl space-y-4 p-6">
<h1 className="text-2xl font-bold"></h1>
<ChatWindow projectId={project.id} userName={user.name} />
</main>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import {
createFileStorageService,
createProjectService,
} from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { Uploader } from '@/components/files/Uploader';
import { FileList } from '@/components/files/FileList';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function FilesPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const fileService = createFileStorageService();
const { items } = fileService.listFiles(user.id, project.id);
const canDelete =
projectService.getMemberRole(user.id, project.id) === 'admin' ||
items.some((f) => f.uploaderId === user.id);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="files" />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<Uploader projectId={project.id} />
<FileList files={items} canDelete={canDelete} />
</main>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createMeetingService, createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { MeetingForm } from '@/components/meetings/MeetingForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function MeetingsPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const meetingService = createMeetingService();
const meetings = meetingService.getMeetings(user.id, project.id);
const members = projectService.getMembers(user.id, project.id).map((m) => ({
userId: m.userId,
name: m.user.name,
}));
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="meetings" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<MeetingForm projectId={project.id} members={members} />
<section className="space-y-3">
{meetings.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
meetings.map((m) => (
<div
key={m.id}
className="rounded-lg border bg-white p-4 shadow-sm"
data-testid={`meeting-${m.id}`}
>
<h3 className="font-semibold text-gray-800">{m.title}</h3>
<p className="text-xs text-gray-500">
{m.startAt} {m.endAt}
</p>
{m.location && (
<p className="text-xs text-gray-500">: {m.location}</p>
)}
{m.minutesMd && (
<p className="mt-2 text-sm text-gray-600"></p>
)}
</div>
))
)}
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { AddMemberForm } from '@/components/project/AddMemberForm';
import { RemoveMemberButton } from '@/components/project/RemoveMemberButton';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const ROLE_LABEL = { admin: '管理者', member: 'メンバー', guest: 'ゲスト' };
export default async function ProjectMembersPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const service = createProjectService();
let project: Awaited<ReturnType<typeof service.getProject>>;
let members: Awaited<ReturnType<typeof service.getMembers>>;
let canManage: boolean;
try {
project = service.getProject(user.id, Number(projectId));
members = service.getMembers(user.id, Number(projectId));
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="members" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
{canManage && <AddMemberForm projectId={project.id} />}
<section className="rounded-lg border bg-white shadow-sm">
<ul className="divide-y">
{members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between p-4"
>
<div>
<p className="font-medium text-gray-800">
{member.user.name}
{member.userId === user.id && (
<span className="ml-2 text-xs text-gray-400">
()
</span>
)}
</p>
<p className="text-sm text-gray-500">{member.user.email}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">
{ROLE_LABEL[member.role] ?? member.role}
</span>
{canManage && member.userId !== user.id && (
<RemoveMemberButton
projectId={project.id}
userId={member.userId}
label="削除"
/>
)}
</div>
</li>
))}
</ul>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import {
createScheduleService,
createProjectService,
} from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { MilestoneForm } from '@/components/calendar/MilestoneForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
function progressColor(progress: number): string {
if (progress >= 67) return 'bg-green-500';
if (progress >= 34) return 'bg-yellow-500';
return 'bg-red-500';
}
export default async function MilestonesPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const scheduleService = createScheduleService();
const milestones = scheduleService.getMilestones(user.id, project.id);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="milestones" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<MilestoneForm projectId={project.id} />
<section className="space-y-3">
{milestones.length === 0 ? (
<p className="text-sm text-gray-400">
</p>
) : (
milestones.map((m) => (
<div
key={m.id}
className="rounded-lg border bg-white p-4 shadow-sm"
data-testid={`milestone-${m.id}`}
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">{m.title}</h3>
<span className="text-xs text-gray-500">
{m.status}
{m.dueDate ? ` / 期限: ${m.dueDate}` : ''}
</span>
</div>
{m.description && (
<p className="mt-1 text-sm text-gray-600">{m.description}</p>
)}
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-gray-500">
<span></span>
<span data-testid={`milestone-progress-${m.id}`}>
{m.progress}%
</span>
</div>
<div className="mt-1 h-2 w-full rounded bg-gray-200">
<div
className={`h-2 rounded ${progressColor(m.progress)}`}
style={{ width: `${m.progress}%` }}
/>
</div>
</div>
</div>
))
)}
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,63 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createNoteService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { MarkdownBody } from '@/components/board/MarkdownBody';
import { NoteEditor } from '@/components/notes/NoteEditor';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
import type { ProjectNote } from '@/lib/types';
export const dynamic = 'force-dynamic';
export default async function NoteDetailPage({
params,
}: {
params: Promise<{ projectId: string; noteId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId, noteId } = await params;
const noteService = createNoteService();
let note: ProjectNote;
try {
note = noteService.getNote(user.id, Number(noteId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect(`/projects/${projectId}/notes`);
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={Number(projectId)} active="notes" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<a
href={`/projects/${projectId}/notes`}
className="text-sm text-blue-600 hover:underline"
>
</a>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<h1 className="text-2xl font-bold">
{note.isPinned === 1 && '📌 '}
{note.title}
</h1>
{note.tags && (
<p className="mt-1 text-xs text-gray-500">{note.tags}</p>
)}
<div className="mt-4">
<MarkdownBody bodyMd={note.bodyMd} />
</div>
<p className="mt-4 text-xs text-gray-400">
: {note.createdAt} / : {note.updatedAt}
</p>
</div>
<NoteEditor projectId={Number(projectId)} note={note} />
</main>
</div>
);
}

View File

@ -0,0 +1,74 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createNoteService, createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { NoteForm } from '@/components/notes/NoteForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function NotesPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const { q, page } = await searchParams;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const noteService = createNoteService();
const { items } = noteService.listNotes(user.id, project.id, {
page: Number(page ?? '1') || 1,
search: q,
});
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="notes" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold">Markdownメモ</h1>
<NoteForm projectId={project.id} />
<section className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
items.map((note) => (
<a
key={note.id}
href={`/projects/${project.id}/notes/${note.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">
{note.isPinned === 1 && '📌 '}
{note.title}
</h3>
{note.tags && (
<span className="text-xs text-gray-500">{note.tags}</span>
)}
</div>
<p className="mt-1 text-xs text-gray-400">{note.updatedAt}</p>
</a>
))
)}
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,146 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createDashboardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { DashboardWidget } from '@/components/project/DashboardWidget';
import { ForbiddenError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function ProjectOverviewPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const service = createDashboardService();
let dashboard;
try {
dashboard = service.getProjectDashboard(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError) {
redirect('/dashboard');
}
throw error;
}
const { project } = dashboard;
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="overview" />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{project.name}</h1>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{project.status}
</span>
</div>
{project.description && (
<p className="text-gray-600">{project.description}</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
<DashboardWidget title="進行中ToDo" empty="ありません">
{dashboard.inProgressTodos.map((t) => (
<p key={t.id} className="text-sm">
{t.title}
{t.dueDate ? `(期限: ${t.dueDate}` : ''}
</p>
))}
</DashboardWidget>
<DashboardWidget title="期限が近いToDo (7日以内)" empty="ありません">
{dashboard.nearDueTodos.map((t) => (
<p key={t.id} className="text-sm">
{t.title}{t.dueDate}
</p>
))}
</DashboardWidget>
<DashboardWidget title="最新チャット (5件)" empty="ありません">
{dashboard.latestChat.map((m) => (
<p key={m.id} className="text-sm">
{m.body}
</p>
))}
</DashboardWidget>
<DashboardWidget title="最新掲示板 (5件)" empty="ありません">
{dashboard.latestBoard.map((t) => (
<a
key={t.id}
href={`/projects/${project.id}/board/${t.id}`}
className="block text-sm text-blue-600 hover:underline"
>
{t.isPinned === 1 ? '📌 ' : ''}
{t.title}
</a>
))}
</DashboardWidget>
<DashboardWidget title="最新メモ (5件)" empty="ありません">
{dashboard.latestNotes.map((n) => (
<a
key={n.id}
href={`/projects/${project.id}/notes/${n.id}`}
className="block text-sm text-blue-600 hover:underline"
>
{n.isPinned === 1 ? '📌 ' : ''}
{n.title}
</a>
))}
</DashboardWidget>
<DashboardWidget title="最近のファイル (5件)" empty="ありません">
{dashboard.recentFiles.map((f) => (
<p key={f.id} className="text-sm">
{f.originalName}
</p>
))}
</DashboardWidget>
<DashboardWidget title="次回ミーティング" empty="予定なし">
{dashboard.nextMeeting && (
<p className="text-sm">
{dashboard.nextMeeting.title}{dashboard.nextMeeting.startAt}
</p>
)}
</DashboardWidget>
<DashboardWidget title="マイルストーン進捗" empty="ありません">
{dashboard.milestones.map((m) => (
<div key={m.id}>
<p className="text-sm">
{m.title} - {m.progress}%
</p>
<div className="h-1.5 w-full rounded bg-gray-200">
<div
className="h-1.5 rounded bg-blue-500"
style={{ width: `${m.progress}%` }}
/>
</div>
</div>
))}
</DashboardWidget>
<DashboardWidget
title="最近のアクティビティ (10件)"
empty="ありません"
>
{dashboard.recentActivity.map((l) => (
<p key={l.id} className="text-sm">
{l.action}{l.targetType}
</p>
))}
</DashboardWidget>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createSearchService, createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { SearchForm } from '@/components/project/SearchForm';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const TYPE_LINKS: Record<string, (id: number, projectId: number) => string> = {
thread: (id, p) => `/projects/${p}/board/${id}`,
note: (id, p) => `/projects/${p}/notes/${id}`,
todo: (_id, p) => `/projects/${p}/todos`,
file: (id) => `/api/files/${id}/download`,
event: (_id, p) => `/projects/${p}/calendar`,
meeting: (_id, p) => `/projects/${p}/meetings`,
milestone: (_id, p) => `/projects/${p}/milestones`,
chat: (_id, p) => `/projects/${p}/chat`,
};
export default async function SearchPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ q?: string; type?: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const { q, type } = await searchParams;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const searchService = createSearchService();
const results = q
? searchService.search(user.id, project.id, {
q,
type: type as never,
})
: [];
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="search" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<SearchForm
projectId={project.id}
initialQ={q ?? ''}
initialType={type ?? ''}
/>
<section className="space-y-2">
{q && results.length === 0 && (
<p className="text-sm text-gray-400"></p>
)}
{results.map((r) => {
const href =
TYPE_LINKS[r.type]?.(r.id, project.id) ??
`/projects/${project.id}`;
return (
<a
key={`${r.type}-${r.id}`}
href={href}
className="block rounded border bg-white p-3 shadow-sm hover:shadow-md"
data-testid={`search-result-${r.type}-${r.id}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-800">{r.title}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{r.type}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">{r.snippet}</p>
</a>
);
})}
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { ProjectSettingsForm } from '@/components/project/ProjectSettingsForm';
import { DeleteProjectButton } from '@/components/project/DeleteProjectButton';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function ProjectSettingsPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const service = createProjectService();
let project;
let canManage;
try {
project = service.getProject(user.id, Number(projectId));
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="settings" />
<main className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<ProjectSettingsForm project={project} canManage={canManage} />
</div>
{canManage && (
<div className="rounded-lg border border-red-200 bg-white p-6 shadow-sm">
<h2 className="text-sm font-semibold text-red-700"></h2>
<p className="mt-1 text-sm text-gray-600">
</p>
<DeleteProjectButton projectId={project.id} />
</div>
)}
</main>
</div>
);
}

View File

@ -0,0 +1,49 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createTodoService, createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { KanbanBoard } from '@/components/todo/KanbanBoard';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function TodosPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const todoService = createTodoService();
const columns = todoService.getColumns(user.id, project.id);
const items = todoService.getItems(user.id, project.id);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="todos" />
<main className="space-y-4 p-6">
<h1 className="text-2xl font-bold">ToDo / Kanban</h1>
<KanbanBoard
projectId={project.id}
columns={columns}
initialItems={items}
/>
</main>
</div>
);
}

View File

@ -0,0 +1,47 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function BackupCreateButton() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onCreate() {
setLoading(true);
setError(null);
const res = await fetch('/api/admin/backups', { method: 'POST' });
setLoading(false);
if (res.ok) {
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<p className="text-sm text-gray-600">
DBファイル + uploadsディレクトリをZIP化してバックアップを作成します
</p>
<button
type="button"
onClick={onCreate}
disabled={loading}
className="mt-2 rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
data-testid="create-backup"
>
{loading ? '作成中...' : 'バックアップ作成'}
</button>
{error && (
<p className="mt-2 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,65 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function CommentForm({
projectId,
threadId,
}: {
projectId: number;
threadId: number;
}) {
const router = useRouter();
const [bodyMd, setBodyMd] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch(
`/api/projects/${projectId}/board/threads/${threadId}/comments`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bodyMd }),
}
);
setLoading(false);
if (res.ok) {
setBodyMd('');
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '投稿に失敗しました');
}
}
return (
<form onSubmit={onSubmit} className="space-y-2">
<textarea
placeholder="コメントMarkdown"
value={bodyMd}
onChange={(e) => setBodyMd(e.target.value)}
className="min-h-[80px] w-full rounded border px-3 py-2"
required
/>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '投稿中...' : 'コメント投稿'}
</button>
</form>
);
}

View File

@ -0,0 +1,20 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
/**
* Markdown本文を安全にレンダリングする。
* HTML直接入力は無効化し、rehype-sanitize でサニタイズする。
*/
export function MarkdownBody({ bodyMd }: { bodyMd: string }) {
return (
<div className="prose prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
>
{bodyMd}
</ReactMarkdown>
</div>
);
}

View File

@ -0,0 +1,99 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
const CATEGORIES = [
{ value: '', label: 'なし' },
{ value: 'notice', label: 'notice' },
{ value: 'spec', label: 'spec' },
{ value: 'minutes', label: 'minutes' },
{ value: 'question', label: 'question' },
{ value: 'decision', label: 'decision' },
{ value: 'trouble', label: 'trouble' },
{ value: 'memo', label: 'memo' },
] as const;
export function ThreadForm({ projectId }: { projectId: number }) {
const router = useRouter();
const [title, setTitle] = useState('');
const [bodyMd, setBodyMd] = useState('');
const [category, setCategory] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}/board/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
bodyMd,
category: category || undefined,
}),
});
setLoading(false);
if (res.ok) {
const data = (await res.json()) as { thread: { id: number } };
router.push(`/projects/${projectId}/board/${data.thread.id}`);
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
>
<div className="flex gap-2">
<input
type="text"
placeholder="タイトル"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1 rounded border px-3 py-2"
required
maxLength={200}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded border px-2 py-2"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<textarea
placeholder="本文Markdown"
value={bodyMd}
onChange={(e) => setBodyMd(e.target.value)}
className="min-h-[120px] w-full rounded border px-3 py-2"
required
/>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'スレッド作成'}
</button>
</form>
);
}

View File

@ -0,0 +1,78 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function CalendarEventForm({
projectId,
defaultDate,
}: {
projectId: number;
defaultDate: string;
}) {
const router = useRouter();
const [title, setTitle] = useState('');
const [startAt, setStartAt] = useState(`${defaultDate}T10:00:00`);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}/calendar/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, type: 'custom', startAt }),
});
setLoading(false);
if (res.ok) {
setTitle('');
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
>
<div className="flex-1">
<label className="block text-sm font-medium"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium"></label>
<input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
className="mt-1 rounded border px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'イベント作成'}
</button>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</form>
);
}

View File

@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { CalendarEventView } from '@/services/ScheduleService';
import {
getMonthGrid,
getWeekDays,
parseISODate,
stepAnchor,
toISODate,
eventsOnDay,
type CalendarViewMode,
} from '@/lib/calendar/grid';
import { WEEKDAY_LABELS } from '@/components/calendar/sourceColors';
import { MonthView } from '@/components/calendar/MonthView';
import { WeekView } from '@/components/calendar/WeekView';
import { DayView } from '@/components/calendar/DayView';
import { EventDetailDialog } from '@/components/calendar/EventDetailDialog';
const VIEW_LABELS: { mode: CalendarViewMode; label: string }[] = [
{ mode: 'month', label: '月' },
{ mode: 'week', label: '週' },
{ mode: 'day', label: '日' },
];
/**
* カレンダーUIのクライアントコンテナ。
* 表示モード切替・前/次/今日ナビ・詳細ダイアログを管理し、
* データ取得はURL searchParams経由でServer Componentに委ねる。
*/
export function CalendarView({
projectId,
events,
view,
anchorDate,
todayKey,
}: {
projectId: number;
events: CalendarEventView[];
view: CalendarViewMode;
anchorDate: string;
todayKey: string;
}) {
const router = useRouter();
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
const anchor = parseISODate(anchorDate);
function navigate(nextView: CalendarViewMode, nextAnchor: Date) {
const params = new URLSearchParams({
view: nextView,
date: toISODate(nextAnchor),
});
router.push(`/projects/${projectId}/calendar?${params.toString()}`);
}
function changeView(nextView: CalendarViewMode) {
navigate(nextView, anchor);
}
function goPrev() {
navigate(view, stepAnchor(view, anchor, -1));
}
function goNext() {
navigate(view, stepAnchor(view, anchor, 1));
}
function goToday() {
navigate(view, new Date());
}
function openDetail(dateKey: string) {
setSelectedDateKey(dateKey);
}
const title = buildTitle(view, anchor);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2
className="text-xl font-bold text-gray-800"
data-testid="calendar-title"
>
{title}
</h2>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded border">
{VIEW_LABELS.map((v) => (
<button
key={v.mode}
type="button"
onClick={() => changeView(v.mode)}
className={`px-3 py-1 text-sm ${
view === v.mode
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
data-testid={`calendar-view-${v.mode}`}
>
{v.label}
</button>
))}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goPrev}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
aria-label="前へ"
data-testid="calendar-prev"
>
</button>
<button
type="button"
onClick={goToday}
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
data-testid="calendar-today"
>
</button>
<button
type="button"
onClick={goNext}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
aria-label="次へ"
data-testid="calendar-next"
>
</button>
</div>
</div>
</div>
{view === 'month' && (
<MonthView
events={events}
weeks={getMonthGrid(anchor.getFullYear(), anchor.getMonth())}
anchorMonth={anchor.getMonth()}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{view === 'week' && (
<WeekView
events={events}
days={getWeekDays(anchor)}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{view === 'day' && (
<DayView
day={anchor}
events={events}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{selectedDateKey && (
<EventDetailDialog
date={parseISODate(selectedDateKey)}
events={eventsOnDay(events, selectedDateKey)}
onClose={() => setSelectedDateKey(null)}
/>
)}
</div>
);
}
function buildTitle(view: CalendarViewMode, anchor: Date): string {
if (view === 'month') {
return `${anchor.getFullYear()}${anchor.getMonth() + 1}`;
}
if (view === 'week') {
const days = getWeekDays(anchor);
const s = days[0];
const e = days[6];
return `${s.getFullYear()}${s.getMonth() + 1}${s.getDate()}日 〜 ${e.getMonth() + 1}${e.getDate()}`;
}
return `${anchor.getFullYear()}${anchor.getMonth() + 1}${anchor.getDate()}日(${WEEKDAY_LABELS[anchor.getDay()]})`;
}

View File

@ -0,0 +1,121 @@
'use client';
import type { CalendarEventView } from '@/services/ScheduleService';
import {
eventsOnDay,
eventHour,
formatTime,
getDayHours,
toISODate,
} from '@/lib/calendar/grid';
import {
SOURCE_COLORS,
SOURCE_CHIP_BORDER,
WEEKDAY_LABELS,
} from '@/components/calendar/sourceColors';
/**
* 日表示(時間グリッド)。0:00〜23:00 を1時間刻みで表示し、
* 時刻付きイベントを該当時刻行に、終日(日付のみ)イベントを上部に配置する。
*/
export function DayView({
day,
events,
todayKey,
onSelectDate,
}: {
day: Date;
events: CalendarEventView[];
todayKey: string;
onSelectDate: (dateKey: string) => void;
}) {
const key = toISODate(day);
const dayEvents = eventsOnDay(events, key);
const allDay = dayEvents.filter((e) => eventHour(e.startAt) === null);
const timed = dayEvents.filter((e) => eventHour(e.startAt) !== null);
const hours = getDayHours();
const isToday = key === todayKey;
return (
<div className="rounded-lg border bg-white">
<div className="flex items-center justify-between border-b p-3">
<button
type="button"
onClick={() => onSelectDate(key)}
className="text-left"
>
<p className="text-lg font-bold text-gray-800">
{day.getMonth() + 1}{day.getDate()}(
{WEEKDAY_LABELS[day.getDay()]})
</p>
</button>
{isToday && (
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
</span>
)}
</div>
{allDay.length > 0 && (
<div
className="border-b bg-gray-50 p-2"
data-testid="calendar-all-day-section"
>
<p className="mb-1 text-xs font-medium text-gray-500"></p>
<div className="flex flex-wrap gap-1">
{allDay.map((e) => (
<button
key={e.key}
type="button"
onClick={() => onSelectDate(key)}
title={e.title}
className={`max-w-full truncate rounded border px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
data-testid={`calendar-event-${e.key}`}
>
{e.title}
</button>
))}
</div>
</div>
)}
<div className="max-h-[600px] overflow-y-auto">
{hours.map((h) => {
const hourEvents = timed.filter((e) => eventHour(e.startAt) === h);
return (
<div
key={h}
className="flex border-b last:border-b-0"
data-testid={`calendar-hour-${String(h).padStart(2, '0')}`}
>
<div className="w-16 shrink-0 border-r bg-gray-50 p-1 text-right text-xs text-gray-400">
{String(h).padStart(2, '0')}:00
</div>
<div className="flex-1 p-1">
{hourEvents.map((e) => (
<button
key={e.key}
type="button"
onClick={() => onSelectDate(key)}
title={e.title}
className={`mb-1 block w-full truncate rounded border px-2 py-1 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
data-testid={`calendar-event-${e.key}`}
>
<span className="font-mono text-[10px] opacity-70">
{formatTime(e.startAt)}
</span>{' '}
{e.title}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
'use client';
import { useEffect, useRef } from 'react';
import type { CalendarEventView } from '@/services/ScheduleService';
import { formatTime } from '@/lib/calendar/grid';
import {
SOURCE_COLORS,
SOURCE_LABELS,
} from '@/components/calendar/sourceColors';
/**
* 指定日のイベント詳細ダイアログ。
* セル内ではtruncateされるタイトルも、ここでは全文と説明を表示する。
* 開閉時にフォーカスをダイアログへ移し、閉じたら元の要素へ戻す。
*/
export function EventDetailDialog({
date,
events,
onClose,
}: {
date: Date;
events: CalendarEventView[];
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => {
previouslyFocused?.focus?.();
};
}, []);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const dateLabel = `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
onClick={onClose}
data-testid="calendar-detail-backdrop"
>
<div
ref={dialogRef}
tabIndex={-1}
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`${dateLabel}のイベント`}
data-testid="calendar-detail-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">{dateLabel}</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="閉じる"
data-testid="calendar-detail-close"
>
×
</button>
</div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
{events.length === 0 ? (
<p className="text-sm text-gray-400">
</p>
) : (
events.map((e) => (
<div
key={e.key}
className="rounded border border-gray-200 p-3"
data-testid={`calendar-detail-${e.key}`}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-gray-800 break-words">
{e.title}
</p>
<span
className={`shrink-0 rounded px-2 py-0.5 text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
}`}
>
{SOURCE_LABELS[e.source] ?? e.source}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
{formatTime(e.startAt)}
{e.endAt ? `${formatTime(e.endAt)}` : ''}
</p>
{e.description && (
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600">
{e.description}
</p>
)}
</div>
))
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function MilestoneForm({ projectId }: { projectId: number }) {
const router = useRouter();
const [title, setTitle] = useState('');
const [dueDate, setDueDate] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}/milestones`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, dueDate: dueDate || null }),
});
setLoading(false);
if (res.ok) {
setTitle('');
setDueDate('');
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
>
<div className="flex-1">
<label className="block text-sm font-medium"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium"></label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="mt-1 rounded border px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'マイルストーン作成'}
</button>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</form>
);
}

View File

@ -0,0 +1,105 @@
'use client';
import type { CalendarEventView } from '@/services/ScheduleService';
import { eventsOnDay, toISODate } from '@/lib/calendar/grid';
import {
SOURCE_COLORS,
SOURCE_CHIP_BORDER,
WEEKDAY_LABELS,
} from '@/components/calendar/sourceColors';
const MAX_VISIBLE_CHIPS = 3;
/**
* 月表示グリッド。日曜始まりの7列グリッドで各日にイベントチップを表示する。
* 長いタイトルは truncate し、3件を超える分は「+N件」でまとめる。
*/
export function MonthView({
events,
weeks,
anchorMonth,
todayKey,
onSelectDate,
}: {
events: CalendarEventView[];
weeks: Date[][];
anchorMonth: number;
todayKey: string;
onSelectDate: (dateKey: string) => void;
}) {
return (
<div className="overflow-hidden rounded-lg border bg-white">
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
{WEEKDAY_LABELS.map((w) => (
<div key={w} className="py-2">
{w}
</div>
))}
</div>
<div>
{weeks.map((week, wi) => (
<div key={wi} className="grid grid-cols-7 border-b last:border-b-0">
{week.map((day) => {
const key = toISODate(day);
const dayEvents = eventsOnDay(events, key);
const inMonth = day.getMonth() === anchorMonth;
const isToday = key === todayKey;
const visible = dayEvents.slice(0, MAX_VISIBLE_CHIPS);
const hidden = dayEvents.length - visible.length;
return (
<div
key={key}
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
inMonth ? 'bg-white' : 'bg-gray-50'
}`}
data-testid={`calendar-day-${key}`}
>
<button
type="button"
onClick={() => onSelectDate(key)}
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
isToday
? 'bg-blue-600 font-bold text-white'
: inMonth
? 'text-gray-700 hover:bg-gray-100'
: 'text-gray-300 hover:bg-gray-100'
}`}
aria-label={`${key} の詳細を開く`}
>
{day.getDate()}
</button>
<div className="mt-1 space-y-0.5">
{visible.map((e) => (
<button
key={e.key}
type="button"
onClick={() => onSelectDate(key)}
title={e.title}
className={`block w-full truncate rounded border px-1 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
data-testid={`calendar-event-${e.key}`}
>
{e.title}
</button>
))}
{hidden > 0 && (
<button
type="button"
onClick={() => onSelectDate(key)}
aria-label={`${key}の残り${hidden}件を開く`}
className="block w-full truncate text-left text-xs text-gray-400 hover:text-gray-600"
>
+{hidden}
</button>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
'use client';
import type { CalendarEventView } from '@/services/ScheduleService';
import { eventsOnDay, formatTime, toISODate } from '@/lib/calendar/grid';
import {
SOURCE_COLORS,
SOURCE_CHIP_BORDER,
WEEKDAY_LABELS,
} from '@/components/calendar/sourceColors';
/**
* 週表示。日〜土の7日を並べ、各日のイベントを開始時刻付きで表示する。
*/
export function WeekView({
events,
days,
todayKey,
onSelectDate,
}: {
events: CalendarEventView[];
days: Date[];
todayKey: string;
onSelectDate: (dateKey: string) => void;
}) {
return (
<div className="overflow-hidden rounded-lg border bg-white">
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
{days.map((d) => {
const key = toISODate(d);
return (
<button
key={key}
type="button"
onClick={() => onSelectDate(key)}
className="py-2 hover:bg-gray-100"
data-testid={`calendar-weekday-${key}`}
>
<div className="text-gray-500">{WEEKDAY_LABELS[d.getDay()]}</div>
<div
className={`mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-sm ${
key === todayKey
? 'bg-blue-600 font-bold text-white'
: 'text-gray-700'
}`}
>
{d.getDate()}
</div>
</button>
);
})}
</div>
<div className="grid grid-cols-7">
{days.map((d) => {
const key = toISODate(d);
const dayEvents = eventsOnDay(events, key);
return (
<div
key={key}
className="min-h-[400px] border-r p-1 last:border-r-0"
data-testid={`calendar-week-cell-${key}`}
>
<div className="space-y-1">
{dayEvents.length === 0 ? (
<p className="px-1 py-1 text-xs text-gray-300">-</p>
) : (
dayEvents.map((e) => (
<button
key={e.key}
type="button"
onClick={() => onSelectDate(key)}
title={e.title}
className={`block w-full truncate rounded border px-1 py-0.5 text-left text-xs ${
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
data-testid={`calendar-event-${e.key}`}
>
<span className="font-mono text-[10px] opacity-70">
{formatTime(e.startAt)}
</span>{' '}
{e.title}
</button>
))
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
/** カレンダーイベントの来源ごとの色とラベル。 */
export const SOURCE_COLORS: Record<string, string> = {
event: 'bg-blue-100 text-blue-700',
milestone: 'bg-purple-100 text-purple-700',
todo: 'bg-yellow-100 text-yellow-700',
};
export const SOURCE_CHIP_BORDER: Record<string, string> = {
event: 'border-blue-200',
milestone: 'border-purple-200',
todo: 'border-yellow-200',
};
export const SOURCE_LABELS: Record<string, string> = {
event: 'イベント',
milestone: 'マイルストーン',
todo: 'ToDo',
};
export const WEEKDAY_LABELS = [
'日',
'月',
'火',
'水',
'木',
'金',
'土',
] as const;

View File

@ -0,0 +1,133 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import type { ChatMessage } from '@/lib/types';
interface ChatWindowProps {
projectId: number;
userName: string;
}
export function ChatWindow({ projectId, userName }: ChatWindowProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null);
// 履歴取得
useEffect(() => {
fetch(`/api/projects/${projectId}/chat/messages`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.items) setMessages(data.items as ChatMessage[]);
})
.catch(() => undefined);
}, [projectId]);
// SSE接続(自動再接続はEventSourceが行う)
useEffect(() => {
const es = new EventSource(`/api/projects/${projectId}/chat/stream`);
es.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as {
type: string;
data: { message?: ChatMessage; id?: number };
};
if (parsed.type === 'chat.message.created' && parsed.data.message) {
setMessages((prev) =>
prev.some((m) => m.id === parsed.data.message!.id)
? prev
: [parsed.data.message!, ...prev]
);
} else if (
parsed.type === 'chat.message.updated' &&
parsed.data.message
) {
setMessages((prev) =>
prev.map((m) =>
m.id === parsed.data.message!.id ? parsed.data.message! : m
)
);
} else if (parsed.type === 'chat.message.deleted' && parsed.data.id) {
setMessages((prev) => prev.filter((m) => m.id !== parsed.data.id));
}
} catch {
// 無効なペイロードは無視
}
};
return () => es.close();
}, [projectId]);
async function onSend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!input.trim()) return;
setError(null);
const res = await fetch(`/api/projects/${projectId}/chat/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: input }),
});
if (res.ok) {
setInput('');
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '送信に失敗しました');
}
}
return (
<div className="flex h-[70vh] flex-col rounded-lg border bg-white shadow-sm">
<div className="flex-1 overflow-y-auto p-4">
<ul className="space-y-2" data-testid="chat-messages">
{messages.map((m) => (
<li
key={m.id}
className={`flex flex-col ${m.authorId === 0 ? 'items-end' : 'items-start'}`}
data-message-id={m.id}
>
<span
className={`max-w-[80%] rounded-lg px-3 py-2 ${
m.body.includes(`@${userName}`)
? 'bg-yellow-100 text-gray-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{m.body}
</span>
<span className="mt-0.5 text-xs text-gray-400">
{m.createdAt}
</span>
</li>
))}
</ul>
</div>
<form
onSubmit={onSend}
className="flex gap-2 border-t p-3"
data-testid="chat-form"
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="メッセージを入力(@email でメンション)"
className="flex-1 rounded border px-3 py-2"
data-testid="chat-input"
/>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
data-testid="chat-send"
>
</button>
</form>
{error && (
<p className="px-3 pb-2 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { FileAsset } from '@/lib/types';
export function FileList({
files,
canDelete,
}: {
files: FileAsset[];
canDelete: boolean;
}) {
const router = useRouter();
const [lightbox, setLightbox] = useState<FileAsset | null>(null);
async function onDelete(fileId: number) {
const res = await fetch(`/api/files/${fileId}`, { method: 'DELETE' });
if (res.ok) router.refresh();
}
if (files.length === 0) {
return <p className="text-sm text-gray-400"></p>;
}
return (
<>
<ul
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
data-testid="file-list"
>
{files.map((file) => {
const isImage = file.mimeType.startsWith('image/');
const isPdf = file.mimeType === 'application/pdf';
const url = `/api/files/${file.id}/download`;
return (
<li
key={file.id}
className="rounded-lg border bg-white p-3 shadow-sm"
data-testid={`file-item-${file.id}`}
>
{isImage ? (
<button
type="button"
onClick={() => setLightbox(file)}
className="block w-full"
>
<img
src={url}
alt={file.originalName}
className="h-24 w-full rounded object-cover"
/>
</button>
) : isPdf ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
>
PDF
</a>
) : (
<a
href={url}
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
>
</a>
)}
<p
className="mt-2 truncate text-xs text-gray-700"
title={file.originalName}
>
{file.originalName}
</p>
<p className="text-xs text-gray-400">{file.size} bytes</p>
{canDelete && (
<button
type="button"
onClick={() => onDelete(file.id)}
className="mt-1 text-xs text-red-600 hover:underline"
>
</button>
)}
</li>
);
})}
</ul>
{lightbox && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setLightbox(null)}
data-testid="lightbox"
>
<img
src={`/api/files/${lightbox.id}/download`}
alt={lightbox.originalName}
className="max-h-full max-w-full rounded"
/>
</div>
)}
</>
);
}

View File

@ -0,0 +1,58 @@
'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
export function Uploader({ projectId }: { projectId: number }) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setLoading(true);
setError(null);
const form = new FormData();
form.append('file', file);
const res = await fetch(`/api/projects/${projectId}/files`, {
method: 'POST',
body: form,
});
setLoading(false);
if (res.ok) {
if (inputRef.current) inputRef.current.value = '';
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? 'アップロードに失敗しました');
}
}
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<label className="block text-sm font-medium">
</label>
<input
ref={inputRef}
type="file"
onChange={onFile}
disabled={loading}
className="mt-2 block text-sm"
data-testid="file-input"
/>
{loading && (
<p className="mt-1 text-sm text-gray-500">...</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser';
import { NotificationBadge } from '@/components/notifications/NotificationBadge';
export function Header({ user }: { user: PublicUser }) {
const router = useRouter();
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
}
return (
<header className="flex items-center justify-between border-b bg-white px-6 py-3">
<a href="/dashboard" className="font-bold text-gray-800">
</a>
<nav className="flex items-center gap-4 text-sm">
<a href="/dashboard" className="text-gray-600 hover:underline">
</a>
<NotificationBadge />
<a href="/profile" className="text-gray-600 hover:underline">
{user.name}
</a>
<button
type="button"
onClick={handleLogout}
className="text-blue-600 hover:underline"
>
</button>
</nav>
</header>
);
}

View File

@ -0,0 +1,78 @@
const NAV_ITEMS = [
{ href: '', label: '概要' },
{ href: '/board', label: '掲示板' },
{ href: '/notes', label: 'メモ' },
{ href: '/chat', label: 'チャット' },
{ href: '/todos', label: 'ToDo' },
{ href: '/files', label: 'ファイル' },
{ href: '/calendar', label: 'カレンダー' },
{ href: '/milestones', label: 'マイルストーン' },
{ href: '/meetings', label: 'ミーティング' },
{ href: '/search', label: '検索' },
{ href: '/members', label: 'メンバー' },
{ href: '/activity', label: 'アクティビティ' },
{ href: '/settings', label: '設定' },
] as const;
/**
* プロジェクト内サブナビゲーション。
* href は `/projects/{projectId}` を起点とする相対パス。
*/
export function ProjectNav({
projectId,
active,
}: {
projectId: number;
active:
| 'overview'
| 'board'
| 'notes'
| 'chat'
| 'todos'
| 'files'
| 'calendar'
| 'milestones'
| 'meetings'
| 'search'
| 'members'
| 'activity'
| 'settings';
}) {
const activeMap: Record<string, boolean> = {
overview: active === 'overview',
board: active === 'board',
notes: active === 'notes',
chat: active === 'chat',
todos: active === 'todos',
files: active === 'files',
calendar: active === 'calendar',
milestones: active === 'milestones',
meetings: active === 'meetings',
search: active === 'search',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',
};
return (
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm">
{NAV_ITEMS.map((item) => {
const key = item.href === '' ? 'overview' : item.href.slice(1);
const href = `/projects/${projectId}${item.href}`;
return (
<a
key={item.href}
href={href}
className={
activeMap[key]
? 'font-medium text-blue-600'
: 'text-gray-600 hover:underline'
}
>
{item.label}
</a>
);
})}
</nav>
);
}

View File

@ -0,0 +1,9 @@
/**
* プロジェクトページ共通のサイドバー。
* プロジェクト内ナビゲーションは ProjectNav に委譲する。
*/
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<aside className="w-48 shrink-0 border-r bg-gray-50 p-4">{children}</aside>
);
}

View File

@ -0,0 +1,46 @@
export interface ScheduleConflict {
userId: number;
type: 'meeting' | 'calendar_event' | 'important_todo';
refId: number;
title: string;
startAt: string;
endAt: string | null;
}
const TYPE_LABELS: Record<string, string> = {
meeting: 'ミーティング',
calendar_event: 'カレンダーイベント',
important_todo: '期限の近い重要タスク',
};
export function ConflictWarning({
conflicts,
}: {
conflicts: ScheduleConflict[];
}) {
if (conflicts.length === 0) {
return (
<p className="text-sm text-green-600" data-testid="no-conflicts">
</p>
);
}
return (
<div
className="rounded border border-yellow-300 bg-yellow-50 p-3"
data-testid="conflict-warning"
>
<p className="text-sm font-semibold text-yellow-800">
:
</p>
<ul className="mt-1 list-inside list-disc text-sm text-yellow-800">
{conflicts.map((c, i) => (
<li key={i}>
[{TYPE_LABELS[c.type] ?? c.type}] {c.title}{c.startAt}
{c.endAt ? `${c.endAt}` : ''}
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,143 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import {
ConflictWarning,
type ScheduleConflict,
} from '@/components/meetings/ConflictWarning';
interface Member {
userId: number;
name: string;
}
export function MeetingForm({
projectId,
members,
}: {
projectId: number;
members: Member[];
}) {
const router = useRouter();
const [title, setTitle] = useState('');
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
const [selected, setSelected] = useState<number[]>([]);
const [conflicts, setConflicts] = useState<ScheduleConflict[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
setConflicts(null);
const res = await fetch(`/api/projects/${projectId}/meetings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, startAt, endAt, memberIds: selected }),
});
setLoading(false);
if (res.ok) {
const data = (await res.json()) as {
meeting: { id: number };
conflicts: ScheduleConflict[];
};
setConflicts(data.conflicts);
setTitle('');
setStartAt('');
setEndAt('');
setSelected([]);
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
function toggleMember(userId: number) {
setSelected((prev) =>
prev.includes(userId)
? prev.filter((u) => u !== userId)
: [...prev, userId]
);
}
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
data-testid="meeting-form"
>
<div>
<label className="block text-sm font-medium"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
data-testid="meeting-title"
/>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-sm font-medium"></label>
<input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
data-testid="meeting-start"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium"></label>
<input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
data-testid="meeting-end"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium"></label>
<div className="mt-1 flex flex-wrap gap-2">
{members.map((m) => (
<label
key={m.userId}
className="flex items-center gap-1 rounded border px-2 py-1 text-sm"
>
<input
type="checkbox"
checked={selected.includes(m.userId)}
onChange={() => toggleMember(m.userId)}
/>
{m.name}
</label>
))}
</div>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'ミーティング作成'}
</button>
{conflicts && <ConflictWarning conflicts={conflicts} />}
</form>
);
}

View File

@ -0,0 +1,102 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import type { ProjectNote } from '@/lib/types';
export function NoteEditor({
projectId,
note,
}: {
projectId: number;
note: ProjectNote;
}) {
const router = useRouter();
const [title, setTitle] = useState(note.title);
const [bodyMd, setBodyMd] = useState(note.bodyMd);
const [tags, setTags] = useState(note.tags ?? '');
const [isPinned, setIsPinned] = useState(note.isPinned === 1);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
setSaved(false);
const res = await fetch(`/api/projects/${projectId}/notes/${note.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
bodyMd,
tags: tags || null,
isPinned: isPinned ? 1 : 0,
}),
});
setLoading(false);
if (res.ok) {
setSaved(true);
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '更新に失敗しました');
}
}
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
>
<h2 className="text-sm font-semibold text-gray-700"></h2>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded border px-3 py-2"
required
maxLength={200}
/>
<textarea
value={bodyMd}
onChange={(e) => setBodyMd(e.target.value)}
className="min-h-[160px] w-full rounded border px-3 py-2"
required
/>
<div className="flex gap-2">
<input
type="text"
placeholder="タグ(カンマ区切り)"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="flex-1 rounded border px-3 py-2"
/>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={isPinned}
onChange={(e) => setIsPinned(e.target.checked)}
/>
</label>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
{saved && <p className="text-sm text-green-600"></p>}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '保存中...' : '保存'}
</button>
</form>
);
}

View File

@ -0,0 +1,78 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function NoteForm({ projectId }: { projectId: number }) {
const router = useRouter();
const [title, setTitle] = useState('');
const [bodyMd, setBodyMd] = useState('');
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, bodyMd, tags: tags || undefined }),
});
setLoading(false);
if (res.ok) {
const data = (await res.json()) as { note: { id: number } };
router.push(`/projects/${projectId}/notes/${data.note.id}`);
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(b?.error?.message ?? '作成に失敗しました');
}
}
return (
<form
onSubmit={onSubmit}
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
>
<input
type="text"
placeholder="タイトル"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded border px-3 py-2"
required
maxLength={200}
/>
<textarea
placeholder="本文Markdown"
value={bodyMd}
onChange={(e) => setBodyMd(e.target.value)}
className="min-h-[120px] w-full rounded border px-3 py-2"
required
/>
<input
type="text"
placeholder="タグ(カンマ区切り)"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full rounded border px-3 py-2"
/>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'メモ作成'}
</button>
</form>
);
}

View File

@ -0,0 +1,31 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function MarkReadButton({ notificationId }: { notificationId: number }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onRead() {
setBusy(true);
const res = await fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
});
setBusy(false);
if (res.ok) {
router.refresh();
}
}
return (
<button
type="button"
onClick={onRead}
disabled={busy}
className="text-xs text-gray-500 hover:underline disabled:opacity-50"
>
{busy ? '処理中...' : '既読にする'}
</button>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
export function NotificationBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
let active = true;
fetch('/api/notifications?page=1')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (active && data && typeof data.total === 'number') {
setCount(data.total);
}
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
if (count === 0) {
return (
<a href="/notifications" className="text-gray-600 hover:underline">
</a>
);
}
return (
<a
href="/notifications"
className="relative text-gray-600 hover:underline"
data-notification-count={count}
>
<span className="ml-1 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
{count > 99 ? '99+' : count}
</span>
</a>
);
}

View File

@ -0,0 +1,50 @@
import type { Notification } from '@/lib/types';
import { MarkReadButton } from '@/components/notifications/MarkReadButton';
const TYPE_LABELS: Record<string, string> = {
mention: 'メンション',
todo_assigned: 'ToDo担当',
todo_due_soon: 'ToDo期限',
meeting_invited: 'ミーティング招待',
board_commented: '掲示板コメント',
project_added: 'プロジェクト追加',
file_shared: 'ファイル共有',
note_updated: 'メモ更新',
};
export function NotificationList({
notifications,
}: {
notifications: Notification[];
}) {
if (notifications.length === 0) {
return <p className="text-sm text-gray-400"></p>;
}
return (
<ul className="divide-y rounded-lg border bg-white shadow-sm">
{notifications.map((notification) => (
<li key={notification.id} className="p-4">
<div className="flex items-center justify-between">
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
{TYPE_LABELS[notification.type] ?? notification.type}
</span>
<MarkReadButton notificationId={notification.id} />
</div>
<p className="mt-2 font-medium text-gray-800">{notification.title}</p>
{notification.body && (
<p className="mt-1 text-sm text-gray-600">{notification.body}</p>
)}
{notification.projectId !== null && (
<a
href={`/projects/${notification.projectId}`}
className="mt-1 inline-block text-xs text-blue-600 hover:underline"
>
</a>
)}
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function AddMemberForm({ projectId }: { projectId: number }) {
const router = useRouter();
const [email, setEmail] = useState('');
const [role, setRole] = useState('member');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role }),
});
if (res.ok) {
setEmail('');
router.refresh();
return;
}
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? 'メンバー追加に失敗しました');
setLoading(false);
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded border bg-white p-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<label htmlFor="member-email" className="block text-sm font-medium">
</label>
<input
id="member-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
<div>
<label htmlFor="member-role" className="block text-sm font-medium">
</label>
<select
id="member-role"
value={role}
onChange={(e) => setRole(e.target.value)}
className="mt-1 rounded border px-3 py-2"
>
<option value="member">member</option>
<option value="admin">admin</option>
<option value="guest">guest</option>
</select>
</div>
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '追加中...' : 'メンバー追加'}
</button>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</form>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function CreateProjectForm() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: description || undefined }),
});
if (res.ok) {
const data = (await res.json()) as { project: { id: number } };
router.push(`/projects/${data.project.id}`);
return;
}
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました');
setLoading(false);
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
>
<div className="flex-1">
<label htmlFor="project-name" className="block text-sm font-medium">
</label>
<input
id="project-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
maxLength={200}
/>
</div>
<div className="flex-1">
<label
htmlFor="project-description"
className="block text-sm font-medium"
>
</label>
<input
id="project-description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : '新規プロジェクト'}
</button>
{error && (
<p className="text-sm text-red-600 sm:full" role="alert">
{error}
</p>
)}
</form>
);
}

View File

@ -0,0 +1,24 @@
/**
* ダッシュボードの各項目を表示するウィジェットコンテナ。
* M4では骨組みのみ。全項目の集計は M13 で完成させる。
*/
export function DashboardWidget({
title,
children,
empty,
}: {
title: string;
children?: React.ReactNode;
empty?: string;
}) {
return (
<section className="rounded-lg border bg-white p-4 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-gray-700">{title}</h2>
{children ? (
<div className="space-y-2">{children}</div>
) : (
<p className="text-sm text-gray-400">{empty ?? 'データがありません'}</p>
)}
</section>
);
}

View File

@ -0,0 +1,51 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function DeleteProjectButton({ projectId }: { projectId: number }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onDelete() {
if (
!window.confirm('プロジェクトを削除しますか?この操作は取り消せません。')
) {
return;
}
setBusy(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}`, {
method: 'DELETE',
});
setBusy(false);
if (res.ok) {
router.push('/dashboard');
router.refresh();
} else {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? '削除に失敗しました');
}
}
return (
<div className="mt-3">
<button
type="button"
onClick={onDelete}
disabled={busy}
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{busy ? '削除中...' : 'プロジェクトを削除'}
</button>
{error && (
<p className="mt-2 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,40 @@
import type { Project } from '@/lib/types';
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
on_hold: 'On Hold',
completed: 'Completed',
archived: 'Archived',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800',
on_hold: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-200 text-gray-700',
};
export function ProjectCard({ project }: { project: Project }) {
return (
<a
href={`/projects/${project.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">{project.name}</h3>
<span
className={`rounded px-2 py-0.5 text-xs ${
STATUS_COLORS[project.status] ?? 'bg-gray-100 text-gray-700'
}`}
>
{STATUS_LABELS[project.status] ?? project.status}
</span>
</div>
{project.description && (
<p className="mt-2 line-clamp-2 text-sm text-gray-600">
{project.description}
</p>
)}
</a>
);
}

View File

@ -0,0 +1,120 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import type { Project } from '@/lib/types';
export function ProjectSettingsForm({
project,
canManage,
}: {
project: Project;
canManage: boolean;
}) {
const router = useRouter();
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description ?? '');
const [status, setStatus] = useState(project.status);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
setSaved(false);
const res = await fetch(`/api/projects/${project.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || undefined,
status,
}),
});
setLoading(false);
if (res.ok) {
setSaved(true);
router.refresh();
} else {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? '更新に失敗しました');
}
}
if (!canManage) {
return (
<p className="text-sm text-gray-500">
</p>
);
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium">
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium">
</label>
<select
id="status"
value={status}
onChange={(e) =>
setStatus(
e.target.value as 'active' | 'on_hold' | 'completed' | 'archived'
)
}
className="mt-1 rounded border px-3 py-2"
>
<option value="active">active</option>
<option value="on_hold">on_hold</option>
<option value="completed">completed</option>
<option value="archived">archived</option>
</select>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
{saved && (
<p className="text-sm text-green-600"></p>
)}
<button
type="submit"
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '保存中...' : '保存'}
</button>
</form>
);
}

View File

@ -0,0 +1,41 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function RemoveMemberButton({
projectId,
userId,
label,
disabled = false,
}: {
projectId: number;
userId: number;
label: string;
disabled?: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onRemove() {
setBusy(true);
const res = await fetch(`/api/projects/${projectId}/members/${userId}`, {
method: 'DELETE',
});
setBusy(false);
if (res.ok) {
router.refresh();
}
}
return (
<button
type="button"
onClick={onRemove}
disabled={disabled || busy}
className="text-xs text-red-600 hover:underline disabled:opacity-50"
>
{busy ? '削除中...' : label}
</button>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
const TYPES = [
{ value: '', label: 'すべて' },
{ value: 'thread', label: '掲示板' },
{ value: 'chat', label: 'チャット' },
{ value: 'todo', label: 'ToDo' },
{ value: 'file', label: 'ファイル' },
{ value: 'event', label: 'イベント' },
{ value: 'meeting', label: 'ミーティング' },
{ value: 'milestone', label: 'マイルストーン' },
{ value: 'note', label: 'メモ' },
];
export function SearchForm({
projectId,
initialQ,
initialType,
}: {
projectId: number;
initialQ: string;
initialType: string;
}) {
const router = useRouter();
const [q, setQ] = useState(initialQ);
const [type, setType] = useState(initialType);
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const params = new URLSearchParams();
if (q) params.set('q', q);
if (type) params.set('type', type);
router.push(`/projects/${projectId}/search?${params.toString()}`);
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row"
data-testid="search-form"
>
<input
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="キーワード"
className="flex-1 rounded border px-3 py-2"
data-testid="search-input"
/>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="rounded border px-2 py-2"
>
{TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
>
</button>
</form>
);
}

View File

@ -0,0 +1,162 @@
'use client';
import { useCallback, useState, type DragEvent } from 'react';
import type { TodoColumn, TodoItem } from '@/lib/types';
const PRIORITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-700',
normal: 'bg-yellow-100 text-yellow-700',
low: 'bg-gray-100 text-gray-600',
};
export function KanbanBoard({
projectId,
columns,
initialItems,
}: {
projectId: number;
columns: TodoColumn[];
initialItems: TodoItem[];
}) {
const [items, setItems] = useState<TodoItem[]>(initialItems);
const [draggingId, setDraggingId] = useState<number | null>(null);
const reload = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/todos/items`);
if (res.ok) {
const data = (await res.json()) as { items: TodoItem[] };
setItems(data.items);
}
}, [projectId]);
function onDragStart(e: DragEvent<HTMLElement>, itemId: number) {
setDraggingId(itemId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(itemId));
}
function onDrop(e: DragEvent<HTMLElement>, column: TodoColumn) {
e.preventDefault();
const itemId = Number(e.dataTransfer.getData('text/plain'));
if (!itemId) return;
const item = items.find((i) => i.id === itemId);
if (!item || item.columnId === column.id) {
setDraggingId(null);
return;
}
// 楽観的にローカル更新したあとAPIで移動
setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
);
fetch(`/api/projects/${projectId}/todos/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
}).then(() => reload());
setDraggingId(null);
}
async function addTask(columnId: number, title: string) {
const res = await fetch(`/api/projects/${projectId}/todos/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, columnId }),
});
if (res.ok) await reload();
}
async function toggleComplete(itemId: number) {
await fetch(`/api/projects/${projectId}/todos/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toggleComplete: true }),
});
await reload();
}
return (
<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);
return (
<section
key={column.id}
className="w-64 shrink-0 rounded-lg bg-gray-100 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">
{column.name} ({colItems.length})
</h2>
<ul className="space-y-2">
{colItems.map((item) => (
<li
key={item.id}
draggable
onDragStart={(e) => onDragStart(e, item.id)}
className={`cursor-grab rounded border bg-white 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">{item.title}</span>
<button
type="button"
onClick={() => 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 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">
: {item.dueDate}
</span>
)}
</div>
</li>
))}
</ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section>
);
})}
</div>
);
}
function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) {
const [title, setTitle] = useState('');
return (
<form
className="mt-2"
onSubmit={(e) => {
e.preventDefault();
if (!title.trim()) return;
onAdd(title);
setTitle('');
}}
>
<input
type="text"
placeholder="タスクを追加"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
data-testid="new-task-input"
/>
</form>
);
}

View File

@ -235,7 +235,7 @@ components/
├── chat/ # ChatWindow, MessageInput, MessageList ├── chat/ # ChatWindow, MessageInput, MessageList
├── todo/ # KanbanBoard, KanbanColumn, TodoCard ├── todo/ # KanbanBoard, KanbanColumn, TodoCard
├── files/ # FileList, Uploader, Lightbox ├── files/ # FileList, Uploader, Lightbox
├── calendar/ # CalendarView, EventBadge ├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
├── meetings/ # MeetingForm, ConflictWarning ├── meetings/ # MeetingForm, ConflictWarning
├── notes/ # NoteEditor, MarkdownPreview ├── notes/ # NoteEditor, MarkdownPreview
└── notifications/ # NotificationList, NotificationBadge └── notifications/ # NotificationList, NotificationBadge

Some files were not shown because too many files have changed in this diff Show More