feat(m7): markdown notes with CRUD, preview, search, tests, e2e
- ProjectNoteRepository (CRUD, soft-delete, search title/body/tags, pinned-first ordering, index usage) + NoteService (membership permission, author/admin edit/delete, note_updated notification to other members, note_created/note_updated activity logs, pin/tags) - APIs: notes list/create/detail/edit/delete - Screens: notes list + NoteForm, note detail + NoteEditor + MarkdownBody preview, ProjectNav メモ link - Unit tests (ProjectNoteRepository, NoteService) + e2e markdown-notes.spec
This commit is contained in:
69
app/api/projects/[projectId]/notes/[noteId]/route.ts
Normal file
69
app/api/projects/[projectId]/notes/[noteId]/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { noteId } = await params;
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
const note = service.getNote(user.id, Number(noteId));
|
||||
return NextResponse.json({ note });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { noteId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
const note = service.updateNote(user.id, Number(noteId), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
bodyMd: typeof body.bodyMd === 'string' ? body.bodyMd : undefined,
|
||||
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||
isPinned: typeof body.isPinned === 'number' ? body.isPinned : undefined,
|
||||
});
|
||||
return NextResponse.json({ note });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; noteId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { noteId } = await params;
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
service.deleteNote(user.id, Number(noteId));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
54
app/api/projects/[projectId]/notes/route.ts
Normal file
54
app/api/projects/[projectId]/notes/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService } from '@/lib/api/services';
|
||||
import { UnauthorizedError } from '@/lib/errors';
|
||||
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
|
||||
const search = request.nextUrl.searchParams.get('q') ?? undefined;
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
return NextResponse.json(
|
||||
service.listNotes(user.id, Number(projectId), { page, search })
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { projectId } = await params;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return jsonError(400, 'リクエスト本文が不正です');
|
||||
}
|
||||
|
||||
const service = createNoteService();
|
||||
try {
|
||||
const note = service.createNote(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
bodyMd: String(body.bodyMd ?? ''),
|
||||
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||
});
|
||||
return NextResponse.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
63
app/projects/[projectId]/notes/[noteId]/page.tsx
Normal file
63
app/projects/[projectId]/notes/[noteId]/page.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { MarkdownBody } from '@/components/board/MarkdownBody';
|
||||
import { NoteEditor } from '@/components/notes/NoteEditor';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
import type { ProjectNote } from '@/lib/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function NoteDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string; noteId: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId, noteId } = await params;
|
||||
|
||||
const noteService = createNoteService();
|
||||
let note: ProjectNote;
|
||||
try {
|
||||
note = noteService.getNote(user.id, Number(noteId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect(`/projects/${projectId}/notes`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={Number(projectId)} active="notes" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<a
|
||||
href={`/projects/${projectId}/notes`}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
← メモ一覧へ
|
||||
</a>
|
||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{note.isPinned === 1 && '📌 '}
|
||||
{note.title}
|
||||
</h1>
|
||||
{note.tags && (
|
||||
<p className="mt-1 text-xs text-gray-500">{note.tags}</p>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<MarkdownBody bodyMd={note.bodyMd} />
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
作成: {note.createdAt} / 更新: {note.updatedAt}
|
||||
</p>
|
||||
</div>
|
||||
<NoteEditor projectId={Number(projectId)} note={note} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
app/projects/[projectId]/notes/page.tsx
Normal file
74
app/projects/[projectId]/notes/page.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||
import { createNoteService, createProjectService } from '@/lib/api/services';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { NoteForm } from '@/components/notes/NoteForm';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function NotesPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
const { q, page } = await searchParams;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
try {
|
||||
project = projectService.getProject(user.id, Number(projectId));
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const noteService = createNoteService();
|
||||
const { items } = noteService.listNotes(user.id, project.id, {
|
||||
page: Number(page ?? '1') || 1,
|
||||
search: q,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="notes" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">Markdownメモ</h1>
|
||||
<NoteForm projectId={project.id} />
|
||||
<section className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">メモはありません。</p>
|
||||
) : (
|
||||
items.map((note) => (
|
||||
<a
|
||||
key={note.id}
|
||||
href={`/projects/${project.id}/notes/${note.id}`}
|
||||
className="block rounded-lg border bg-white p-4 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
{note.isPinned === 1 && '📌 '}
|
||||
{note.title}
|
||||
</h3>
|
||||
{note.tags && (
|
||||
<span className="text-xs text-gray-500">{note.tags}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">{note.updatedAt}</p>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
const NAV_ITEMS = [
|
||||
{ href: '', label: '概要' },
|
||||
{ href: '/board', label: '掲示板' },
|
||||
{ href: '/notes', label: 'メモ' },
|
||||
{ href: '/members', label: 'メンバー' },
|
||||
{ href: '/activity', label: 'アクティビティ' },
|
||||
{ href: '/settings', label: '設定' },
|
||||
@ -15,11 +16,12 @@ export function ProjectNav({
|
||||
active,
|
||||
}: {
|
||||
projectId: number;
|
||||
active: 'overview' | 'board' | 'members' | 'activity' | 'settings';
|
||||
active: 'overview' | 'board' | 'notes' | 'members' | 'activity' | 'settings';
|
||||
}) {
|
||||
const activeMap: Record<string, boolean> = {
|
||||
overview: active === 'overview',
|
||||
board: active === 'board',
|
||||
notes: active === 'notes',
|
||||
members: active === 'members',
|
||||
activity: active === 'activity',
|
||||
settings: active === 'settings',
|
||||
|
||||
102
components/notes/NoteEditor.tsx
Normal file
102
components/notes/NoteEditor.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ProjectNote } from '@/lib/types';
|
||||
|
||||
export function NoteEditor({
|
||||
projectId,
|
||||
note,
|
||||
}: {
|
||||
projectId: number;
|
||||
note: ProjectNote;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState(note.title);
|
||||
const [bodyMd, setBodyMd] = useState(note.bodyMd);
|
||||
const [tags, setTags] = useState(note.tags ?? '');
|
||||
const [isPinned, setIsPinned] = useState(note.isPinned === 1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
const res = await fetch(`/api/projects/${projectId}/notes/${note.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
bodyMd,
|
||||
tags: tags || null,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '更新に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-700">編集</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded border px-3 py-2"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
<textarea
|
||||
value={bodyMd}
|
||||
onChange={(e) => setBodyMd(e.target.value)}
|
||||
className="min-h-[160px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タグ(カンマ区切り)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPinned}
|
||||
onChange={(e) => setIsPinned(e.target.checked)}
|
||||
/>
|
||||
ピン留め
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && <p className="text-sm text-green-600">メモを更新しました</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
78
components/notes/NoteForm.tsx
Normal file
78
components/notes/NoteForm.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function NoteForm({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [bodyMd, setBodyMd] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/projects/${projectId}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, bodyMd, tags: tags || undefined }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { note: { id: number } };
|
||||
router.push(`/projects/${projectId}/notes/${data.note.id}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '作成に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タイトル"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded border px-3 py-2"
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="本文(Markdown)"
|
||||
value={bodyMd}
|
||||
onChange={(e) => setBodyMd(e.target.value)}
|
||||
className="min-h-[120px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="タグ(カンマ区切り)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full rounded border px-3 py-2"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'メモ作成'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,8 @@ import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { BoardService } from '@/services/BoardService';
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { NoteService } from '@/services/NoteService';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
|
||||
/**
|
||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||
@ -42,6 +44,16 @@ export function createBoardService(): BoardService {
|
||||
);
|
||||
}
|
||||
|
||||
export function createNoteService(): NoteService {
|
||||
const db = getDb();
|
||||
return new NoteService(
|
||||
new ProjectNoteRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
}
|
||||
|
||||
export function createUserRepository(): UserRepository {
|
||||
return new UserRepository(getDb());
|
||||
}
|
||||
|
||||
177
repositories/ProjectNoteRepository.ts
Normal file
177
repositories/ProjectNoteRepository.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { ProjectNote } from '@/lib/types';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProjectNoteRow {
|
||||
id: number;
|
||||
project_id: number;
|
||||
title: string;
|
||||
body_md: string;
|
||||
tags: string | null;
|
||||
is_pinned: number;
|
||||
created_by_id: number;
|
||||
updated_by_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
function mapNote(row: ProjectNoteRow): ProjectNote {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
title: row.title,
|
||||
bodyMd: row.body_md,
|
||||
tags: row.tags,
|
||||
isPinned: row.is_pinned,
|
||||
createdById: row.created_by_id,
|
||||
updatedById: row.updated_by_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
projectId: number;
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
tags: string | null;
|
||||
createdById: number;
|
||||
}
|
||||
|
||||
export interface UpdateNoteInput {
|
||||
title?: string;
|
||||
bodyMd?: string;
|
||||
tags?: string | null;
|
||||
isPinned?: number;
|
||||
updatedById: number;
|
||||
}
|
||||
|
||||
export interface ListNotesOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class ProjectNoteRepository {
|
||||
constructor(private readonly db: SqliteDatabase) {}
|
||||
|
||||
findNotes(
|
||||
projectId: number,
|
||||
opts: ListNotesOptions = {}
|
||||
): Paginated<ProjectNote> {
|
||||
const page = opts.page ?? 1;
|
||||
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const search = opts.search?.trim();
|
||||
const orderClause =
|
||||
'ORDER BY is_pinned DESC, updated_at DESC, id DESC LIMIT @pageSize OFFSET @offset';
|
||||
|
||||
if (search) {
|
||||
const like = `%${search}%`;
|
||||
const items = this.db.query<ProjectNoteRow>(
|
||||
`SELECT * FROM project_notes
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||
AND (title LIKE @like OR body_md LIKE @like OR tags LIKE @like)
|
||||
${orderClause}`,
|
||||
{ projectId, like, pageSize, offset }
|
||||
);
|
||||
const total = this.db.get<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM project_notes
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||
AND (title LIKE @like OR body_md LIKE @like OR tags LIKE @like)`,
|
||||
{ projectId, like }
|
||||
);
|
||||
return { items: items.map(mapNote), total: total?.count ?? 0 };
|
||||
}
|
||||
|
||||
const items = this.db.query<ProjectNoteRow>(
|
||||
`SELECT * FROM project_notes
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||
${orderClause}`,
|
||||
{ projectId, pageSize, offset }
|
||||
);
|
||||
const total = this.db.get<{ count: number }>(
|
||||
'SELECT COUNT(*) AS count FROM project_notes WHERE project_id = @projectId AND deleted_at IS NULL',
|
||||
{ projectId }
|
||||
);
|
||||
return { items: items.map(mapNote), total: total?.count ?? 0 };
|
||||
}
|
||||
|
||||
findNoteById(id: number): ProjectNote | null {
|
||||
const row = this.db.get<ProjectNoteRow>(
|
||||
'SELECT * FROM project_notes WHERE id = @id AND deleted_at IS NULL',
|
||||
{ id }
|
||||
);
|
||||
return row ? mapNote(row) : null;
|
||||
}
|
||||
|
||||
create(input: CreateNoteInput): ProjectNote {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @title, @bodyMd, @tags, 0, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
tags: input.tags,
|
||||
createdById: input.createdById,
|
||||
updatedById: input.createdById,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
);
|
||||
const created = this.findNoteById(Number(result.lastInsertRowid));
|
||||
if (!created) throw new Error('Failed to create note');
|
||||
return created;
|
||||
}
|
||||
|
||||
update(id: number, input: UpdateNoteInput): ProjectNote | null {
|
||||
const fields: string[] = [
|
||||
'updated_at = @updatedAt',
|
||||
'updated_by_id = @updatedById',
|
||||
];
|
||||
const params: Record<string, unknown> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedById: input.updatedById,
|
||||
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.tags !== undefined) {
|
||||
fields.push('tags = @tags');
|
||||
params.tags = input.tags;
|
||||
}
|
||||
if (input.isPinned !== undefined) {
|
||||
fields.push('is_pinned = @isPinned');
|
||||
params.isPinned = input.isPinned;
|
||||
}
|
||||
this.db.execute(
|
||||
`UPDATE project_notes SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`,
|
||||
params
|
||||
);
|
||||
return this.findNoteById(id);
|
||||
}
|
||||
|
||||
delete(id: number): boolean {
|
||||
const result = this.db.execute(
|
||||
'UPDATE project_notes SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
||||
{ now: new Date().toISOString(), id }
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
164
services/NoteService.ts
Normal file
164
services/NoteService.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import {
|
||||
ProjectNoteRepository,
|
||||
type ListNotesOptions,
|
||||
} from '@/repositories/ProjectNoteRepository';
|
||||
import type { Paginated } from '@/repositories/ProjectNoteRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { ProjectNote } from '@/lib/types';
|
||||
|
||||
export interface CreateNoteInput {
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNoteInput {
|
||||
title?: string;
|
||||
bodyMd?: string;
|
||||
tags?: string | null;
|
||||
isPinned?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdownメモの業務ロジックを担うService。
|
||||
* 権限チェック、作成者/管理者による編集削除、更新時の note_updated 通知と
|
||||
* note_created/note_updated アクティビティログ記録を行う。
|
||||
*/
|
||||
export class NoteService {
|
||||
constructor(
|
||||
private readonly noteRepository: ProjectNoteRepository,
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly activityLogService: ActivityLogService
|
||||
) {}
|
||||
|
||||
listNotes(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
opts: ListNotesOptions = {}
|
||||
): Paginated<ProjectNote> {
|
||||
this.requireMember(projectId, actorId);
|
||||
return this.noteRepository.findNotes(projectId, opts);
|
||||
}
|
||||
|
||||
getNote(actorId: number, noteId: number): ProjectNote {
|
||||
const note = this.noteRepository.findNoteById(noteId);
|
||||
if (!note) throw new NotFoundError('ProjectNote', noteId);
|
||||
this.requireMember(note.projectId, actorId);
|
||||
return note;
|
||||
}
|
||||
|
||||
createNote(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
input: CreateNoteInput
|
||||
): ProjectNote {
|
||||
this.requireMember(projectId, actorId);
|
||||
this.validateNoteInput(input.title, input.bodyMd);
|
||||
const note = this.noteRepository.create({
|
||||
projectId,
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
tags: input.tags ?? null,
|
||||
createdById: actorId,
|
||||
});
|
||||
this.activityLogService.logActivity({
|
||||
projectId,
|
||||
actorId,
|
||||
action: 'note_created',
|
||||
targetType: 'note',
|
||||
targetId: note.id,
|
||||
});
|
||||
return note;
|
||||
}
|
||||
|
||||
updateNote(
|
||||
actorId: number,
|
||||
noteId: number,
|
||||
input: UpdateNoteInput
|
||||
): ProjectNote {
|
||||
const note = this.getNote(actorId, noteId);
|
||||
this.requireAuthorOrAdmin(note.projectId, actorId, note.createdById);
|
||||
if (input.title !== undefined || input.bodyMd !== undefined) {
|
||||
this.validateNoteInput(
|
||||
input.title ?? note.title,
|
||||
input.bodyMd ?? note.bodyMd
|
||||
);
|
||||
}
|
||||
const updated = this.noteRepository.update(noteId, {
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
tags: input.tags,
|
||||
isPinned: input.isPinned,
|
||||
updatedById: actorId,
|
||||
});
|
||||
if (!updated) throw new NotFoundError('ProjectNote', noteId);
|
||||
|
||||
// プロジェクトメンバーへ更新通知(操作者本人を除く)
|
||||
const memberIds = this.projectMemberRepository
|
||||
.findByProject(note.projectId)
|
||||
.map((m) => m.userId)
|
||||
.filter((uid) => uid !== actorId);
|
||||
if (memberIds.length > 0) {
|
||||
this.notificationService.notifyOnEvent({
|
||||
type: 'note_updated',
|
||||
projectId: note.projectId,
|
||||
title: `メモ「${updated.title}」が更新されました`,
|
||||
body: updated.bodyMd.slice(0, 100),
|
||||
projectMemberIds: memberIds,
|
||||
});
|
||||
}
|
||||
this.activityLogService.logActivity({
|
||||
projectId: note.projectId,
|
||||
actorId,
|
||||
action: 'note_updated',
|
||||
targetType: 'note',
|
||||
targetId: note.id,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteNote(actorId: number, noteId: number): void {
|
||||
const note = this.getNote(actorId, noteId);
|
||||
this.requireAuthorOrAdmin(note.projectId, actorId, note.createdById);
|
||||
this.noteRepository.delete(noteId);
|
||||
}
|
||||
|
||||
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 validateNoteInput(title: string, bodyMd: string): void {
|
||||
if (!title.trim()) {
|
||||
throw new ValidationError('タイトルを入力してください', 'title');
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw new ValidationError(
|
||||
'タイトルは200文字以内で入力してください',
|
||||
'title'
|
||||
);
|
||||
}
|
||||
if (!bodyMd.trim()) {
|
||||
throw new ValidationError('本文を入力してください', 'bodyMd');
|
||||
}
|
||||
}
|
||||
}
|
||||
76
tests/e2e/markdown-notes.spec.ts
Normal file
76
tests/e2e/markdown-notes.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
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('markdown notes', () => {
|
||||
test('create, preview, pin, edit, and search notes', async ({ page }) => {
|
||||
const projectId = await setupOwner(page);
|
||||
const title = unique('Note');
|
||||
|
||||
const res = await page.request.post(`/api/projects/${projectId}/notes`, {
|
||||
data: {
|
||||
title,
|
||||
bodyMd: '# Heading\n\n- item1\n- item2',
|
||||
tags: 'alpha,beta',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { note } = (await res.json()) as { note: { id: number } };
|
||||
|
||||
// 一覧に表示
|
||||
await page.goto(`/projects/${projectId}/notes`);
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(title) })
|
||||
).toBeVisible();
|
||||
|
||||
// 詳細: Markdownがレンダリングされる
|
||||
await page.goto(`/projects/${projectId}/notes/${note.id}`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Heading', level: 1 })
|
||||
).toBeVisible();
|
||||
await expect(page.locator('li').filter({ hasText: 'item1' })).toBeVisible();
|
||||
|
||||
// ピン留め + 編集(API)
|
||||
const patchRes = await page.request.patch(
|
||||
`/api/projects/${projectId}/notes/${note.id}`,
|
||||
{ data: { isPinned: 1, bodyMd: '# Updated\n\nedited content' } }
|
||||
);
|
||||
expect(patchRes.ok()).toBeTruthy();
|
||||
await page.goto(`/projects/${projectId}/notes/${note.id}`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Updated', level: 1 })
|
||||
).toBeVisible();
|
||||
|
||||
// 検索
|
||||
const otherTitle = unique('ZZZ');
|
||||
await page.request.post(`/api/projects/${projectId}/notes`, {
|
||||
data: { title: otherTitle, bodyMd: 'body' },
|
||||
});
|
||||
await page.goto(
|
||||
`/projects/${projectId}/notes?q=${encodeURIComponent(otherTitle)}`
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(otherTitle) })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(title) })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
111
tests/unit/repositories/ProjectNoteRepository.test.ts
Normal file
111
tests/unit/repositories/ProjectNoteRepository.test.ts
Normal file
@ -0,0 +1,111 @@
|
||||
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 { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
|
||||
describe('ProjectNoteRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let repo: ProjectNoteRepository;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
repo = new ProjectNoteRepository(db);
|
||||
userId = new UserRepository(db).create({
|
||||
name: 'U',
|
||||
email: 'u@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
function createNote(title: string, tags: string | null = null) {
|
||||
return repo.create({
|
||||
projectId,
|
||||
title,
|
||||
bodyMd: 'body',
|
||||
tags,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
it('creates and finds a note', () => {
|
||||
const n = createNote('N1', 'tag1,tag2');
|
||||
expect(repo.findNoteById(n.id)?.title).toBe('N1');
|
||||
expect(repo.findNoteById(n.id)?.tags).toBe('tag1,tag2');
|
||||
});
|
||||
|
||||
it('lists notes pinned-first then recently updated', () => {
|
||||
const a = createNote('a');
|
||||
const b = createNote('b');
|
||||
repo.update(a.id, { isPinned: 1, updatedById: userId });
|
||||
|
||||
const { items } = repo.findNotes(projectId);
|
||||
expect(items[0].id).toBe(a.id);
|
||||
expect(items[1].id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('excludes soft-deleted notes', () => {
|
||||
const n = createNote('N');
|
||||
repo.delete(n.id);
|
||||
expect(repo.findNoteById(n.id)).toBeNull();
|
||||
expect(repo.findNotes(projectId).total).toBe(0);
|
||||
});
|
||||
|
||||
it('isolates notes by project', () => {
|
||||
createNote('mine');
|
||||
const p2 = new ProjectRepository(db).create({
|
||||
name: 'P2',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
repo.create({
|
||||
projectId: p2,
|
||||
title: 'theirs',
|
||||
bodyMd: 'b',
|
||||
tags: null,
|
||||
createdById: userId,
|
||||
});
|
||||
expect(repo.findNotes(projectId).total).toBe(1);
|
||||
expect(repo.findNotes(p2).total).toBe(1);
|
||||
});
|
||||
|
||||
it('searches notes by title/body/tags', () => {
|
||||
createNote('Alpha', 'x');
|
||||
createNote('Beta', 'special');
|
||||
expect(repo.findNotes(projectId, { search: 'alpha' }).total).toBe(1);
|
||||
expect(repo.findNotes(projectId, { search: 'special' }).total).toBe(1);
|
||||
expect(repo.findNotes(projectId, { search: 'nomatch' }).total).toBe(0);
|
||||
});
|
||||
|
||||
it('paginates notes', () => {
|
||||
for (let i = 0; i < 5; i++) createNote(`n${i}`);
|
||||
expect(
|
||||
repo.findNotes(projectId, { page: 1, pageSize: 2 }).items
|
||||
).toHaveLength(2);
|
||||
expect(repo.findNotes(projectId, { page: 1, pageSize: 2 }).total).toBe(5);
|
||||
});
|
||||
|
||||
it('updates note fields and updatedById', () => {
|
||||
const n = createNote('N');
|
||||
const updated = repo.update(n.id, {
|
||||
title: 'N2',
|
||||
bodyMd: 'new',
|
||||
isPinned: 1,
|
||||
updatedById: userId,
|
||||
});
|
||||
expect(updated?.title).toBe('N2');
|
||||
expect(updated?.bodyMd).toBe('new');
|
||||
expect(updated?.isPinned).toBe(1);
|
||||
expect(updated?.updatedById).toBe(userId);
|
||||
});
|
||||
});
|
||||
133
tests/unit/services/NoteService.test.ts
Normal file
133
tests/unit/services/NoteService.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
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 { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { NoteService } from '@/services/NoteService';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
function makeService(db: SqliteDatabase) {
|
||||
return new NoteService(
|
||||
new ProjectNoteRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
}
|
||||
|
||||
describe('NoteService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let service: NoteService;
|
||||
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: 'A',
|
||||
email: 'a@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
memberId = users.create({
|
||||
name: 'M',
|
||||
email: 'm@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
outsiderId = users.create({
|
||||
name: 'O',
|
||||
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('creates a note and records note_created activity', () => {
|
||||
const note = service.createNote(memberId, projectId, {
|
||||
title: 'N',
|
||||
bodyMd: 'body',
|
||||
tags: 't1',
|
||||
});
|
||||
expect(note.title).toBe('N');
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'note_created')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('forbids a non-member from creating a note', () => {
|
||||
expect(() =>
|
||||
service.createNote(outsiderId, projectId, { title: 'N', bodyMd: 'b' })
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('rejects an empty title', () => {
|
||||
expect(() =>
|
||||
service.createNote(memberId, projectId, { title: ' ', bodyMd: 'b' })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('on update, notifies other project members and logs note_updated', () => {
|
||||
const note = service.createNote(authorId, projectId, {
|
||||
title: 'N',
|
||||
bodyMd: 'b',
|
||||
});
|
||||
service.updateNote(authorId, note.id, { bodyMd: 'edited by author' });
|
||||
|
||||
// 他のメンバー(member)は更新通知を受ける
|
||||
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1);
|
||||
// 操作者(author)自身には通知しない
|
||||
expect(new NotificationRepository(db).countUnreadByUser(authorId)).toBe(0);
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'note_updated')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('only author or admin can edit/delete', () => {
|
||||
const users = new UserRepository(db);
|
||||
const member2 = users.create({
|
||||
name: 'M2',
|
||||
email: 'm2@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
new ProjectMemberRepository(db).add(projectId, member2, 'member');
|
||||
const note = service.createNote(memberId, projectId, {
|
||||
title: 'N',
|
||||
bodyMd: 'b',
|
||||
});
|
||||
|
||||
expect(() => service.updateNote(member2, note.id, { title: 'X' })).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
expect(() =>
|
||||
service.updateNote(authorId, note.id, { title: 'X' })
|
||||
).not.toThrow(); // admin
|
||||
expect(() =>
|
||||
service.updateNote(memberId, note.id, { title: 'Y' })
|
||||
).not.toThrow(); // author
|
||||
});
|
||||
|
||||
it('throws NotFoundError for a non-existent note', () => {
|
||||
expect(() => service.getNote(memberId, 99999)).toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user