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:
Ken Yasue
2026-06-25 01:36:10 +02:00
parent fb61a7f592
commit e45aea8e27
13 changed files with 1116 additions and 1 deletions

View 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);
}
}

View 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);
}
}

View 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>
);
}

View 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>
);
}

View File

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

View 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>
);
}

View 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>
);
}

View File

@ -9,6 +9,8 @@ import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { BoardService } from '@/services/BoardService'; import { BoardService } from '@/services/BoardService';
import { BoardRepository } from '@/repositories/BoardRepository'; import { BoardRepository } from '@/repositories/BoardRepository';
import { NoteService } from '@/services/NoteService';
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
/** /**
* Route Handler用に各Repository/Serviceを構築するファクトリ。 * 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 { export function createUserRepository(): UserRepository {
return new UserRepository(getDb()); return new UserRepository(getDb());
} }

View 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
View 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');
}
}
}

View 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);
});
});

View 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);
});
});

View 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);
});
});