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:
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
57
app/api/projects/[projectId]/board/threads/route.ts
Normal file
57
app/api/projects/[projectId]/board/threads/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
app/projects/[projectId]/board/[threadId]/page.tsx
Normal file
78
app/projects/[projectId]/board/[threadId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
app/projects/[projectId]/board/page.tsx
Normal file
77
app/projects/[projectId]/board/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
components/board/CommentForm.tsx
Normal file
65
components/board/CommentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
components/board/MarkdownBody.tsx
Normal file
20
components/board/MarkdownBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
components/board/ThreadForm.tsx
Normal file
99
components/board/ThreadForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
265
repositories/BoardRepository.ts
Normal file
265
repositories/BoardRepository.ts
Normal 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
235
services/BoardService.ts
Normal 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
80
tests/e2e/board.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
tests/unit/repositories/BoardRepository.test.ts
Normal file
127
tests/unit/repositories/BoardRepository.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
170
tests/unit/services/BoardService.test.ts
Normal file
170
tests/unit/services/BoardService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user