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 = [
|
const NAV_ITEMS = [
|
||||||
{ href: '', label: '概要' },
|
{ href: '', label: '概要' },
|
||||||
|
{ href: '/board', label: '掲示板' },
|
||||||
{ href: '/members', label: 'メンバー' },
|
{ href: '/members', label: 'メンバー' },
|
||||||
{ href: '/activity', label: 'アクティビティ' },
|
{ href: '/activity', label: 'アクティビティ' },
|
||||||
{ href: '/settings', label: '設定' },
|
{ href: '/settings', label: '設定' },
|
||||||
@ -14,10 +15,11 @@ export function ProjectNav({
|
|||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
projectId: number;
|
projectId: number;
|
||||||
active: 'overview' | 'members' | 'activity' | 'settings';
|
active: 'overview' | 'board' | 'members' | 'activity' | 'settings';
|
||||||
}) {
|
}) {
|
||||||
const activeMap: Record<string, boolean> = {
|
const activeMap: Record<string, boolean> = {
|
||||||
overview: active === 'overview',
|
overview: active === 'overview',
|
||||||
|
board: active === 'board',
|
||||||
members: active === 'members',
|
members: active === 'members',
|
||||||
activity: active === 'activity',
|
activity: active === 'activity',
|
||||||
settings: active === 'settings',
|
settings: active === 'settings',
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { UserRepository } from '@/repositories/UserRepository';
|
|||||||
import { ProjectService } from '@/services/ProjectService';
|
import { ProjectService } from '@/services/ProjectService';
|
||||||
import { NotificationService } from '@/services/NotificationService';
|
import { NotificationService } from '@/services/NotificationService';
|
||||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||||
|
import { BoardService } from '@/services/BoardService';
|
||||||
|
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||||
@ -30,6 +32,16 @@ export function createActivityLogService(): ActivityLogService {
|
|||||||
return new ActivityLogService(new ActivityLogRepository(getDb()));
|
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 {
|
export function createUserRepository(): UserRepository {
|
||||||
return new UserRepository(getDb());
|
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