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
This commit is contained in:
Ken Yasue
2026-06-25 01:28:08 +02:00
parent ac598a50f2
commit 2017585f84
16 changed files with 1448 additions and 1 deletions

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,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,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

@ -1,5 +1,6 @@
const NAV_ITEMS = [
{ href: '', label: '概要' },
{ href: '/board', label: '掲示板' },
{ href: '/members', label: 'メンバー' },
{ href: '/activity', label: 'アクティビティ' },
{ href: '/settings', label: '設定' },
@ -14,10 +15,11 @@ export function ProjectNav({
active,
}: {
projectId: number;
active: 'overview' | 'members' | 'activity' | 'settings';
active: 'overview' | 'board' | 'members' | 'activity' | 'settings';
}) {
const activeMap: Record<string, boolean> = {
overview: active === 'overview',
board: active === 'board',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',

View File

@ -7,6 +7,8 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectService } from '@/services/ProjectService';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
import { BoardService } from '@/services/BoardService';
import { BoardRepository } from '@/repositories/BoardRepository';
/**
* Route Handler用に各Repository/Serviceを構築するファクトリ。
@ -30,6 +32,16 @@ export function createActivityLogService(): ActivityLogService {
return new ActivityLogService(new ActivityLogRepository(getDb()));
}
export function createBoardService(): BoardService {
const db = getDb();
return new BoardService(
new BoardRepository(db),
new ProjectMemberRepository(db),
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db))
);
}
export function createUserRepository(): UserRepository {
return new UserRepository(getDb());
}

View File

@ -0,0 +1,265 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
const DEFAULT_PAGE_SIZE = 20;
export interface Paginated<T> {
items: T[];
total: number;
}
interface BoardThreadRow {
id: number;
project_id: number;
title: string;
body_md: string;
author_id: number;
category: string | null;
is_pinned: number;
is_important: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
interface BoardCommentRow {
id: number;
thread_id: number;
author_id: number;
body_md: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
function mapThread(row: BoardThreadRow): BoardThread {
return {
id: row.id,
projectId: row.project_id,
title: row.title,
bodyMd: row.body_md,
authorId: row.author_id,
category: row.category as BoardCategory | null,
isPinned: row.is_pinned,
isImportant: row.is_important,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
};
}
function mapComment(row: BoardCommentRow): BoardComment {
return {
id: row.id,
threadId: row.thread_id,
authorId: row.author_id,
bodyMd: row.body_md,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
};
}
export interface CreateThreadInput {
projectId: number;
title: string;
bodyMd: string;
authorId: number;
category: BoardCategory | null;
}
export interface UpdateThreadInput {
title?: string;
bodyMd?: string;
category?: BoardCategory | null;
isPinned?: number;
isImportant?: number;
}
export interface ListThreadsOptions {
page?: number;
pageSize?: number;
search?: string;
}
export class BoardRepository {
constructor(private readonly db: SqliteDatabase) {}
findThreads(
projectId: number,
opts: ListThreadsOptions = {}
): Paginated<BoardThread> {
const page = opts.page ?? 1;
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
const offset = (page - 1) * pageSize;
const search = opts.search?.trim();
if (search) {
const like = `%${search}%`;
const items = this.db.query<BoardThreadRow>(
`SELECT * FROM board_threads
WHERE project_id = @projectId AND deleted_at IS NULL
AND (title LIKE @like OR body_md LIKE @like)
ORDER BY is_pinned DESC, created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`,
{ projectId, like, pageSize, offset }
);
const total = this.db.get<{ count: number }>(
`SELECT COUNT(*) AS count FROM board_threads
WHERE project_id = @projectId AND deleted_at IS NULL
AND (title LIKE @like OR body_md LIKE @like)`,
{ projectId, like }
);
return { items: items.map(mapThread), total: total?.count ?? 0 };
}
const items = this.db.query<BoardThreadRow>(
`SELECT * FROM board_threads
WHERE project_id = @projectId AND deleted_at IS NULL
ORDER BY is_pinned DESC, created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`,
{ projectId, pageSize, offset }
);
const total = this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM board_threads WHERE project_id = @projectId AND deleted_at IS NULL',
{ projectId }
);
return { items: items.map(mapThread), total: total?.count ?? 0 };
}
findThreadById(id: number): BoardThread | null {
const row = this.db.get<BoardThreadRow>(
'SELECT * FROM board_threads WHERE id = @id AND deleted_at IS NULL',
{ id }
);
return row ? mapThread(row) : null;
}
createThread(input: CreateThreadInput): BoardThread {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at)
VALUES (@projectId, @title, @bodyMd, @authorId, @category, 0, 0, @createdAt, @updatedAt, NULL)`,
{
projectId: input.projectId,
title: input.title,
bodyMd: input.bodyMd,
authorId: input.authorId,
category: input.category,
createdAt: now,
updatedAt: now,
}
);
const created = this.findThreadById(Number(result.lastInsertRowid));
if (!created) throw new Error('Failed to create thread');
return created;
}
updateThread(id: number, input: UpdateThreadInput): BoardThread | null {
const fields: string[] = ['updated_at = @updatedAt'];
const params: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
id,
};
if (input.title !== undefined) {
fields.push('title = @title');
params.title = input.title;
}
if (input.bodyMd !== undefined) {
fields.push('body_md = @bodyMd');
params.bodyMd = input.bodyMd;
}
if (input.category !== undefined) {
fields.push('category = @category');
params.category = input.category;
}
if (input.isPinned !== undefined) {
fields.push('is_pinned = @isPinned');
params.isPinned = input.isPinned;
}
if (input.isImportant !== undefined) {
fields.push('is_important = @isImportant');
params.isImportant = input.isImportant;
}
this.db.execute(
`UPDATE board_threads SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`,
params
);
return this.findThreadById(id);
}
deleteThread(id: number): boolean {
const result = this.db.execute(
'UPDATE board_threads SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
{ now: new Date().toISOString(), id }
);
return result.changes > 0;
}
findCommentsByThread(
threadId: number,
page: number = 1,
pageSize: number = DEFAULT_PAGE_SIZE
): Paginated<BoardComment> {
const offset = (page - 1) * pageSize;
const items = this.db.query<BoardCommentRow>(
`SELECT * FROM board_comments
WHERE thread_id = @threadId AND deleted_at IS NULL
ORDER BY created_at ASC, id ASC
LIMIT @pageSize OFFSET @offset`,
{ threadId, pageSize, offset }
);
const total = this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM board_comments WHERE thread_id = @threadId AND deleted_at IS NULL',
{ threadId }
);
return { items: items.map(mapComment), total: total?.count ?? 0 };
}
findCommentById(id: number): BoardComment | null {
const row = this.db.get<BoardCommentRow>(
'SELECT * FROM board_comments WHERE id = @id AND deleted_at IS NULL',
{ id }
);
return row ? mapComment(row) : null;
}
createComment(input: {
threadId: number;
authorId: number;
bodyMd: string;
}): BoardComment {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at)
VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`,
{
threadId: input.threadId,
authorId: input.authorId,
bodyMd: input.bodyMd,
createdAt: now,
updatedAt: now,
}
);
const created = this.findCommentById(Number(result.lastInsertRowid));
if (!created) throw new Error('Failed to create comment');
return created;
}
updateComment(id: number, bodyMd: string): BoardComment | null {
this.db.execute(
`UPDATE board_comments SET body_md = @bodyMd, updated_at = @updatedAt
WHERE id = @id AND deleted_at IS NULL`,
{ bodyMd, updatedAt: new Date().toISOString(), id }
);
return this.findCommentById(id);
}
deleteComment(id: number): boolean {
const result = this.db.execute(
'UPDATE board_comments SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
{ now: new Date().toISOString(), id }
);
return result.changes > 0;
}
}

235
services/BoardService.ts Normal file
View File

@ -0,0 +1,235 @@
import {
BoardRepository,
type ListThreadsOptions,
} from '@/repositories/BoardRepository';
import type { Paginated } from '@/repositories/BoardRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
const VALID_CATEGORIES: BoardCategory[] = [
'notice',
'spec',
'minutes',
'question',
'decision',
'trouble',
'memo',
];
export interface CreateThreadInput {
title: string;
bodyMd: string;
category?: BoardCategory;
}
export interface UpdateThreadInput {
title?: string;
bodyMd?: string;
category?: BoardCategory;
isPinned?: number;
isImportant?: number;
}
/**
* 掲示板の業務ロジックを担うService。
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
* コメント追加時の通知(board_commented)とアクティビティログ記録を行う。
*/
export class BoardService {
constructor(
private readonly boardRepository: BoardRepository,
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService
) {}
listThreads(
actorId: number,
projectId: number,
opts: ListThreadsOptions = {}
): Paginated<BoardThread> {
this.requireMember(projectId, actorId);
return this.boardRepository.findThreads(projectId, opts);
}
getThread(actorId: number, threadId: number): BoardThread {
const thread = this.boardRepository.findThreadById(threadId);
if (!thread) throw new NotFoundError('BoardThread', threadId);
this.requireMember(thread.projectId, actorId);
return thread;
}
createThread(
actorId: number,
projectId: number,
input: CreateThreadInput
): BoardThread {
this.requireMember(projectId, actorId);
this.validateThreadInput(input.title, input.bodyMd, input.category);
const thread = this.boardRepository.createThread({
projectId,
title: input.title,
bodyMd: input.bodyMd,
authorId: actorId,
category: input.category ?? null,
});
this.activityLogService.logActivity({
projectId,
actorId,
action: 'board_posted',
targetType: 'thread',
targetId: thread.id,
});
return thread;
}
updateThread(
actorId: number,
threadId: number,
input: UpdateThreadInput
): BoardThread {
const thread = this.getThread(actorId, threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
if (
input.title !== undefined ||
input.bodyMd !== undefined ||
input.category !== undefined
) {
this.validateThreadInput(
input.title ?? thread.title,
input.bodyMd ?? thread.bodyMd,
input.category
);
}
const updated = this.boardRepository.updateThread(threadId, {
title: input.title,
bodyMd: input.bodyMd,
category: input.category,
isPinned: input.isPinned,
isImportant: input.isImportant,
});
if (!updated) throw new NotFoundError('BoardThread', threadId);
return updated;
}
deleteThread(actorId: number, threadId: number): void {
const thread = this.getThread(actorId, threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
this.boardRepository.deleteThread(threadId);
}
listComments(
actorId: number,
threadId: number,
page: number = 1
): Paginated<BoardComment> {
const thread = this.getThread(actorId, threadId);
return this.boardRepository.findCommentsByThread(thread.id, page);
}
createComment(
actorId: number,
threadId: number,
bodyMd: string
): BoardComment {
const thread = this.getThread(actorId, threadId);
if (!bodyMd.trim()) {
throw new ValidationError('コメント本文を入力してください', 'bodyMd');
}
const comment = this.boardRepository.createComment({
threadId: thread.id,
authorId: actorId,
bodyMd,
});
// スレッド投稿者へ通知(自分自身へのコメントは通知しない)
if (thread.authorId !== actorId) {
this.notificationService.notifyOnEvent({
type: 'board_commented',
projectId: thread.projectId,
title: `スレッド「${thread.title}」にコメントがつきました`,
body: bodyMd.slice(0, 100),
threadAuthorId: thread.authorId,
});
}
this.activityLogService.logActivity({
projectId: thread.projectId,
actorId,
action: 'comment_added',
targetType: 'comment',
targetId: comment.id,
});
return comment;
}
updateComment(
actorId: number,
commentId: number,
bodyMd: string
): BoardComment {
const comment = this.boardRepository.findCommentById(commentId);
if (!comment) throw new NotFoundError('BoardComment', commentId);
this.getThread(actorId, comment.threadId);
if (comment.authorId !== actorId) {
throw new ForbiddenError('自分のコメントのみ編集できます');
}
if (!bodyMd.trim()) {
throw new ValidationError('コメント本文を入力してください', 'bodyMd');
}
const updated = this.boardRepository.updateComment(commentId, bodyMd);
if (!updated) throw new NotFoundError('BoardComment', commentId);
return updated;
}
deleteComment(actorId: number, commentId: number): void {
const comment = this.boardRepository.findCommentById(commentId);
if (!comment) throw new NotFoundError('BoardComment', commentId);
const thread = this.getThread(actorId, comment.threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId);
this.boardRepository.deleteComment(commentId);
}
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
}
}
private requireAuthorOrAdmin(
projectId: number,
actorId: number,
authorId: number
): void {
if (actorId === authorId) return;
const role = this.projectMemberRepository.getRole(projectId, actorId);
if (role !== 'admin') {
throw new ForbiddenError(
'投稿者またはプロジェクト管理者のみ操作できます'
);
}
}
private validateThreadInput(
title: string,
bodyMd: string,
category?: BoardCategory
): void {
if (!title.trim()) {
throw new ValidationError('タイトルを入力してください', 'title');
}
if (title.length > 200) {
throw new ValidationError(
'タイトルは200文字以内で入力してください',
'title'
);
}
if (!bodyMd.trim()) {
throw new ValidationError('本文を入力してください', 'bodyMd');
}
if (category && !VALID_CATEGORIES.includes(category)) {
throw new ValidationError('無効なカテゴリです', 'category');
}
}
}

80
tests/e2e/board.spec.ts Normal file
View File

@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test';
function unique(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
async function setupOwner(page: import('@playwright/test').Page) {
const email = unique('owner') + '@example.com';
await page.goto('/login');
await page.getByRole('button', { name: '新規登録はこちら' }).click();
await page.getByLabel('表示名').fill('Owner');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: '登録する' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
}
test.describe('board', () => {
test('create thread, view detail, comment, and search', async ({ page }) => {
const projectId = await setupOwner(page);
const title = unique('Thread');
// スレッド作成(API)
const res = await page.request.post(
`/api/projects/${projectId}/board/threads`,
{
data: {
title,
bodyMd: '# Hello\n\nthis is **markdown**',
category: 'notice',
},
}
);
expect(res.ok()).toBeTruthy();
const { thread } = (await res.json()) as { thread: { id: number } };
// 一覧に表示される
await page.goto(`/projects/${projectId}/board`);
await expect(
page.getByRole('link', { name: new RegExp(title) })
).toBeVisible();
// 詳細: Markdown本文が表示される
await page.goto(`/projects/${projectId}/board/${thread.id}`);
await expect(
page.getByRole('heading', { name: 'Hello', level: 1 })
).toBeVisible();
await expect(page.getByText('markdown')).toBeVisible();
// コメント追加(API) → 詳細に表示される
const commentRes = await page.request.post(
`/api/projects/${projectId}/board/threads/${thread.id}/comments`,
{ data: { bodyMd: 'first comment' } }
);
expect(commentRes.ok()).toBeTruthy();
await page.reload();
await expect(page.getByText('first comment')).toBeVisible();
// 検索: 別スレッドを作成し、q で絞り込める
const otherTitle = unique('ZZZ');
await page.request.post(`/api/projects/${projectId}/board/threads`, {
data: { title: otherTitle, bodyMd: 'body', category: 'question' },
});
await page.goto(
`/projects/${projectId}/board?q=${encodeURIComponent(otherTitle)}`
);
await expect(
page.getByRole('link', { name: new RegExp(otherTitle) })
).toBeVisible();
// 前のスレッドは検索結果に含まれない
await expect(
page.getByRole('link', { name: new RegExp(title) })
).toHaveCount(0);
});
});

View File

@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { BoardRepository } from '@/repositories/BoardRepository';
describe('BoardRepository', () => {
let db: SqliteDatabase;
let repo: BoardRepository;
let projectId: number;
let authorId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new BoardRepository(db);
authorId = new UserRepository(db).create({
name: 'A',
email: 'a@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: authorId,
}).id;
new ProjectMemberRepository(db).add(projectId, authorId, 'admin');
});
afterEach(() => db.close());
function createThread(title: string, body = 'body') {
return repo.createThread({
projectId,
title,
bodyMd: body,
authorId,
category: 'notice',
});
}
it('creates and finds a thread', () => {
const t = createThread('T1');
expect(repo.findThreadById(t.id)?.title).toBe('T1');
});
it('lists threads pinned-first then newest', () => {
const t1 = createThread('old');
const t2 = createThread('new');
repo.updateThread(t1.id, { isPinned: 1 });
const { items } = repo.findThreads(projectId);
expect(items[0].id).toBe(t1.id);
expect(items[1].id).toBe(t2.id);
});
it('excludes soft-deleted threads', () => {
const t = createThread('T');
repo.deleteThread(t.id);
expect(repo.findThreadById(t.id)).toBeNull();
expect(repo.findThreads(projectId).total).toBe(0);
});
it('isolates threads by project', () => {
createThread('mine');
const otherProject = new ProjectRepository(db).create({
name: 'P2',
ownerId: authorId,
}).id;
repo.createThread({
projectId: otherProject,
title: 'theirs',
bodyMd: 'b',
authorId,
category: null,
});
expect(repo.findThreads(projectId).total).toBe(1);
expect(repo.findThreads(otherProject).total).toBe(1);
});
it('searches threads by title and body', () => {
createThread('Important notice', 'body');
createThread('Other', 'special keyword');
expect(repo.findThreads(projectId, { search: 'important' }).total).toBe(1);
expect(repo.findThreads(projectId, { search: 'keyword' }).total).toBe(1);
expect(repo.findThreads(projectId, { search: 'nomatch' }).total).toBe(0);
});
it('paginates threads', () => {
for (let i = 0; i < 5; i++) createThread(`t${i}`);
expect(
repo.findThreads(projectId, { page: 1, pageSize: 2 }).items
).toHaveLength(2);
expect(repo.findThreads(projectId, { page: 1, pageSize: 2 }).total).toBe(5);
});
it('creates and lists comments (oldest first)', () => {
const t = createThread('T');
const c1 = repo.createComment({
threadId: t.id,
authorId,
bodyMd: 'first',
});
repo.createComment({ threadId: t.id, authorId, bodyMd: 'second' });
const { items, total } = repo.findCommentsByThread(t.id);
expect(total).toBe(2);
expect(items[0].id).toBe(c1.id);
});
it('excludes soft-deleted comments', () => {
const t = createThread('T');
const c = repo.createComment({ threadId: t.id, authorId, bodyMd: 'x' });
repo.deleteComment(c.id);
expect(repo.findCommentById(c.id)).toBeNull();
expect(repo.findCommentsByThread(t.id).total).toBe(0);
});
it('updates a comment body', () => {
const t = createThread('T');
const c = repo.createComment({ threadId: t.id, authorId, bodyMd: 'x' });
const updated = repo.updateComment(c.id, 'updated');
expect(updated?.bodyMd).toBe('updated');
});
});

View File

@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { BoardRepository } from '@/repositories/BoardRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
import { BoardService } from '@/services/BoardService';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
function makeService(db: SqliteDatabase) {
return new BoardService(
new BoardRepository(db),
new ProjectMemberRepository(db),
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db))
);
}
describe('BoardService', () => {
let db: SqliteDatabase;
let service: BoardService;
let projectId: number;
let authorId: number;
let memberId: number;
let outsiderId: number;
beforeEach(() => {
db = createMigratedTestDb();
service = makeService(db);
const users = new UserRepository(db);
authorId = users.create({
name: 'Author',
email: 'a@example.com',
passwordHash: 'h',
}).id;
memberId = users.create({
name: 'Member',
email: 'm@example.com',
passwordHash: 'h',
}).id;
outsiderId = users.create({
name: 'Outsider',
email: 'o@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: authorId,
}).id;
const members = new ProjectMemberRepository(db);
members.add(projectId, authorId, 'admin');
members.add(projectId, memberId, 'member');
});
afterEach(() => db.close());
it('allows a member to create a thread and records activity', () => {
const thread = service.createThread(memberId, projectId, {
title: 'T',
bodyMd: 'body',
category: 'notice',
});
expect(thread.title).toBe('T');
const logs = new ActivityLogRepository(db).findByProject(projectId);
expect(logs.items.some((l) => l.action === 'board_posted')).toBe(true);
});
it('forbids a non-member from creating a thread', () => {
expect(() =>
service.createThread(outsiderId, projectId, { title: 'T', bodyMd: 'b' })
).toThrow(ForbiddenError);
});
it('rejects an empty title', () => {
expect(() =>
service.createThread(memberId, projectId, { title: ' ', bodyMd: 'b' })
).toThrow(ValidationError);
});
it('only the author or admin can edit/delete a thread', () => {
const users = new UserRepository(db);
const member2 = users.create({
name: 'Member2',
email: 'm2@example.com',
passwordHash: 'h',
}).id;
new ProjectMemberRepository(db).add(projectId, member2, 'member');
const thread = service.createThread(memberId, projectId, {
title: 'T',
bodyMd: 'b',
});
// 非作者かつ非管理者は編集不可
expect(() =>
service.updateThread(member2, thread.id, { title: 'X' })
).toThrow(ForbiddenError);
// 管理者は編集可
expect(() =>
service.updateThread(authorId, thread.id, { title: 'X' })
).not.toThrow();
// 作者自身は編集可
expect(() =>
service.updateThread(memberId, thread.id, { title: 'Y' })
).not.toThrow();
// 外部者はアクセス不可
expect(() => service.getThread(outsiderId, thread.id)).toThrow(
ForbiddenError
);
});
it('creates a comment, notifies the thread author (not the commenter), and logs activity', () => {
const thread = service.createThread(authorId, projectId, {
title: 'T',
bodyMd: 'b',
});
const comment = service.createComment(memberId, thread.id, 'nice post');
expect(comment.bodyMd).toBe('nice post');
// 通知はスレッド投稿者(authorId)へ
const notifs = new NotificationRepository(db).findUnreadByUser(authorId);
expect(notifs.total).toBe(1);
// コメントした本人には通知しない
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(0);
const logs = new ActivityLogRepository(db).findByProject(projectId);
expect(logs.items.some((l) => l.action === 'comment_added')).toBe(true);
});
it('does not notify when the author comments on their own thread', () => {
const thread = service.createThread(authorId, projectId, {
title: 'T',
bodyMd: 'b',
});
service.createComment(authorId, thread.id, 'self comment');
expect(new NotificationRepository(db).countUnreadByUser(authorId)).toBe(0);
});
it('only the comment author can edit their comment', () => {
const thread = service.createThread(authorId, projectId, {
title: 'T',
bodyMd: 'b',
});
const comment = service.createComment(memberId, thread.id, 'c');
expect(() => service.updateComment(authorId, comment.id, 'hacked')).toThrow(
ForbiddenError
);
expect(service.updateComment(memberId, comment.id, 'edited').bodyMd).toBe(
'edited'
);
});
it('admin can delete another member comment', () => {
const thread = service.createThread(authorId, projectId, {
title: 'T',
bodyMd: 'b',
});
const comment = service.createComment(memberId, thread.id, 'c');
service.deleteComment(authorId, comment.id); // authorId is admin
expect(() => service.listComments(memberId, thread.id)).not.toThrow();
});
it('throws NotFoundError for a non-existent thread', () => {
expect(() => service.getThread(memberId, 99999)).toThrow(NotFoundError);
});
});