From e45aea8e2765db49e654dd5170944d8498897daf Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 01:36:10 +0200 Subject: [PATCH] feat(m7): markdown notes with CRUD, preview, search, tests, e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../[projectId]/notes/[noteId]/route.ts | 69 +++++++ app/api/projects/[projectId]/notes/route.ts | 54 ++++++ .../[projectId]/notes/[noteId]/page.tsx | 63 +++++++ app/projects/[projectId]/notes/page.tsx | 74 ++++++++ components/layout/ProjectNav.tsx | 4 +- components/notes/NoteEditor.tsx | 102 ++++++++++ components/notes/NoteForm.tsx | 78 ++++++++ lib/api/services.ts | 12 ++ repositories/ProjectNoteRepository.ts | 177 ++++++++++++++++++ services/NoteService.ts | 164 ++++++++++++++++ tests/e2e/markdown-notes.spec.ts | 76 ++++++++ .../ProjectNoteRepository.test.ts | 111 +++++++++++ tests/unit/services/NoteService.test.ts | 133 +++++++++++++ 13 files changed, 1116 insertions(+), 1 deletion(-) create mode 100644 app/api/projects/[projectId]/notes/[noteId]/route.ts create mode 100644 app/api/projects/[projectId]/notes/route.ts create mode 100644 app/projects/[projectId]/notes/[noteId]/page.tsx create mode 100644 app/projects/[projectId]/notes/page.tsx create mode 100644 components/notes/NoteEditor.tsx create mode 100644 components/notes/NoteForm.tsx create mode 100644 repositories/ProjectNoteRepository.ts create mode 100644 services/NoteService.ts create mode 100644 tests/e2e/markdown-notes.spec.ts create mode 100644 tests/unit/repositories/ProjectNoteRepository.test.ts create mode 100644 tests/unit/services/NoteService.test.ts diff --git a/app/api/projects/[projectId]/notes/[noteId]/route.ts b/app/api/projects/[projectId]/notes/[noteId]/route.ts new file mode 100644 index 0000000..9339c41 --- /dev/null +++ b/app/api/projects/[projectId]/notes/[noteId]/route.ts @@ -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; + try { + body = (await request.json()) as Record; + } 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); + } +} diff --git a/app/api/projects/[projectId]/notes/route.ts b/app/api/projects/[projectId]/notes/route.ts new file mode 100644 index 0000000..963fcf5 --- /dev/null +++ b/app/api/projects/[projectId]/notes/route.ts @@ -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; + try { + body = (await request.json()) as Record; + } 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); + } +} diff --git a/app/projects/[projectId]/notes/[noteId]/page.tsx b/app/projects/[projectId]/notes/[noteId]/page.tsx new file mode 100644 index 0000000..8312989 --- /dev/null +++ b/app/projects/[projectId]/notes/[noteId]/page.tsx @@ -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 ( +
+
+ +
+ + ← メモ一覧へ + +
+

+ {note.isPinned === 1 && '📌 '} + {note.title} +

+ {note.tags && ( +

{note.tags}

+ )} +
+ +
+

+ 作成: {note.createdAt} / 更新: {note.updatedAt} +

+
+ +
+
+ ); +} diff --git a/app/projects/[projectId]/notes/page.tsx b/app/projects/[projectId]/notes/page.tsx new file mode 100644 index 0000000..5f8971a --- /dev/null +++ b/app/projects/[projectId]/notes/page.tsx @@ -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>; + 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 ( +
+
+ +
+

Markdownメモ

+ +
+ {items.length === 0 ? ( +

メモはありません。

+ ) : ( + items.map((note) => ( + +
+

+ {note.isPinned === 1 && '📌 '} + {note.title} +

+ {note.tags && ( + {note.tags} + )} +
+

{note.updatedAt}

+
+ )) + )} +
+
+
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index ca65d1a..aecae9f 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -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 = { overview: active === 'overview', board: active === 'board', + notes: active === 'notes', members: active === 'members', activity: active === 'activity', settings: active === 'settings', diff --git a/components/notes/NoteEditor.tsx b/components/notes/NoteEditor.tsx new file mode 100644 index 0000000..bcdfdd7 --- /dev/null +++ b/components/notes/NoteEditor.tsx @@ -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(null); + const [saved, setSaved] = useState(false); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: FormEvent) { + 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 ( +
+

編集

+ setTitle(e.target.value)} + className="w-full rounded border px-3 py-2" + required + maxLength={200} + /> +